feat: implemented global key signature track
This commit is contained in:
@@ -211,6 +211,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.global-tracks-grid-shell.expanded {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.global-tracks-info-shell.collapsed,
|
||||
.global-tracks-grid-shell.collapsed {
|
||||
max-height: 0;
|
||||
@@ -254,7 +258,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.global-track-add-button svg {
|
||||
@@ -303,6 +307,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
.global-key-signature-lane.popup-open {
|
||||
z-index: 1004;
|
||||
}
|
||||
|
||||
.global-marker-lane.pencil-cursor {
|
||||
cursor: crosshair;
|
||||
}
|
||||
@@ -341,6 +349,42 @@
|
||||
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 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||
import { createDefaultGlobalTracks } from '../core/global-track';
|
||||
import { createMockMidiTrack } from '../test/utils/mock-data';
|
||||
|
||||
const executeCommandMock = vi.fn();
|
||||
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 audioRegion = new KGAudioRegion('audio-1', '2', 1, 'Audio 1', 4, 4);
|
||||
@@ -93,7 +94,7 @@ vi.mock('../core/KGCore', () => ({
|
||||
clearSelectedItems: () => {
|
||||
storeState.selectedRegionIds = [];
|
||||
},
|
||||
executeCommand: vi.fn(),
|
||||
executeCommand: executeCommandMock,
|
||||
getCurrentProject: () => ({
|
||||
getTracks: () => storeState.tracks,
|
||||
}),
|
||||
@@ -149,6 +150,7 @@ describe('MainContent', () => {
|
||||
storeState.openSpectrogramViewer.mockClear();
|
||||
storeState.addTrack.mockClear();
|
||||
storeState.addAudioTrack.mockClear();
|
||||
executeCommandMock.mockClear();
|
||||
});
|
||||
|
||||
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('Tempo')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Signature')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Key Signature')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
|
||||
expect(screen.getByText('Marker')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tempo')).toBeInTheDocument();
|
||||
expect(screen.getByText('Signature')).toBeInTheDocument();
|
||||
expect(screen.getByText('Key Signature')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chord')).toBeInTheDocument();
|
||||
|
||||
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('Tempo')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Signature')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Key Signature')).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 />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
|
||||
const addButtons = [
|
||||
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' }),
|
||||
];
|
||||
|
||||
expect(addButtons).toHaveLength(3);
|
||||
expect(addButtons).toHaveLength(2);
|
||||
|
||||
addButtons.forEach(button => {
|
||||
fireEvent.click(button);
|
||||
@@ -349,5 +350,15 @@ describe('MainContent', () => {
|
||||
|
||||
expect(storeState.addTrack).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,15 +3,18 @@ import './MainContent.css';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import type { KeySignature } from '../core/KGProject';
|
||||
import { GlobalTrackType } from '../core/global-track';
|
||||
import { KGTrack } from '../core/track/KGTrack';
|
||||
import { KGRegion } from '../core/region/KGRegion';
|
||||
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGMarkerRegion } from '../core/region/KGMarkerRegion';
|
||||
import TrackInfoPanel from './track/TrackInfoPanel';
|
||||
import TrackGridPanel from './track/TrackGridPanel';
|
||||
import GlobalKeySignatureLane from './global-track/GlobalKeySignatureLane';
|
||||
import GlobalMarkerLane from './global-track/GlobalMarkerLane';
|
||||
import PianoRoll from './piano-roll/PianoRoll';
|
||||
import { TrackCreateDialog } from './common';
|
||||
@@ -23,13 +26,18 @@ import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||
import {
|
||||
ChangeLoopSettingsCommand,
|
||||
CreateGlobalMarkerRegionCommand,
|
||||
CreateKeySignatureRegionCommand,
|
||||
DeleteKeySignatureRegionCommand,
|
||||
DeleteMultipleKeySignatureRegionsCommand,
|
||||
DeleteMultipleGlobalRegionsCommand,
|
||||
DeleteTrackAutomationPointsCommand,
|
||||
MoveGlobalRegionCommand,
|
||||
ResizeKeySignatureRegionCommand,
|
||||
ResizeGlobalRegionCommand,
|
||||
UpdateKeySignatureRegionCommand,
|
||||
UpdateGlobalRegionTextCommand,
|
||||
} 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 { FaSquareArrowUpRight } from 'react-icons/fa6';
|
||||
|
||||
@@ -45,7 +53,7 @@ interface GlobalTrackDefinition {
|
||||
const GLOBAL_TRACKS: GlobalTrackDefinition[] = [
|
||||
{ id: 'marker', label: 'Marker' },
|
||||
{ id: 'tempo', label: 'Tempo' },
|
||||
{ id: 'signature', label: 'Signature' },
|
||||
{ id: 'signature', label: 'Key Signature' },
|
||||
{ id: 'chord', label: 'Chord' },
|
||||
];
|
||||
|
||||
@@ -110,6 +118,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const [animateGlobalTracksMock, setAnimateGlobalTracksMock] = useState(false);
|
||||
const [editingGlobalRegionId, setEditingGlobalRegionId] = useState<string | null>(null);
|
||||
const [editingGlobalRegionText, setEditingGlobalRegionText] = useState('');
|
||||
const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState<string | null>(null);
|
||||
|
||||
// Use the region operations hook
|
||||
const { deleteSelectedRegions } = useRegionOperations({
|
||||
@@ -162,6 +171,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const markerRegions = (markerTrack?.getRegions() ?? []).filter(
|
||||
(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 => {
|
||||
for (const track of tracks) {
|
||||
@@ -193,18 +204,35 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
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)) {
|
||||
setEditingGlobalRegionId(null);
|
||||
setEditingGlobalRegionText('');
|
||||
}
|
||||
if (editingKeySignatureRegionId && selectedGlobalRegionIds.includes(editingKeySignatureRegionId)) {
|
||||
setEditingKeySignatureRegionId(null);
|
||||
}
|
||||
refreshProjectState();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting global marker regions:', error);
|
||||
return false;
|
||||
}
|
||||
}, [editingGlobalRegionId, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
||||
}, [editingGlobalRegionId, editingKeySignatureRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
||||
|
||||
// Register the delete function with the global manager
|
||||
useEffect(() => {
|
||||
@@ -877,6 +905,15 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setEditingGlobalRegionText(region.getName());
|
||||
}, [findProjectRegionById]);
|
||||
|
||||
const beginEditingKeySignatureRegion = useCallback((regionId: string) => {
|
||||
const region = findProjectRegionById(regionId);
|
||||
if (!(region instanceof KGKeySignatureRegion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingKeySignatureRegionId(regionId);
|
||||
}, [findProjectRegionById]);
|
||||
|
||||
const commitGlobalRegionEdit = useCallback((regionId: string) => {
|
||||
const region = findProjectRegionById(regionId);
|
||||
if (!(region instanceof KGMarkerRegion)) {
|
||||
@@ -956,6 +993,57 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}
|
||||
}, [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
|
||||
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
||||
@@ -1306,6 +1394,11 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
e.stopPropagation();
|
||||
if (track.id === 'marker') {
|
||||
createMarkerAtPlayheadBar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.id === 'signature') {
|
||||
createKeySignatureAtPlayheadBar();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1323,6 +1416,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
key={track.id}
|
||||
markerRegions={markerRegions}
|
||||
maxBars={maxBars}
|
||||
barWidthMultiplier={barWidthMultiplier}
|
||||
timeSignature={timeSignature}
|
||||
selectedRegionIds={selectedRegionIds}
|
||||
editingRegionId={editingGlobalRegionId}
|
||||
@@ -1339,6 +1433,22 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
onMoveRegion={moveGlobalMarkerRegion}
|
||||
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
|
||||
key={track.id}
|
||||
|
||||
@@ -2,7 +2,10 @@ import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Toolbar from './Toolbar';
|
||||
import { createDefaultGlobalTracks } from '../core/global-track';
|
||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||
|
||||
const executeCommandMock = vi.fn();
|
||||
const storeState = {
|
||||
projectName: 'Test Project',
|
||||
setProjectName: vi.fn(),
|
||||
@@ -59,6 +62,7 @@ const storeState = {
|
||||
requestMainContentScroll: vi.fn(),
|
||||
requestPianoRollScroll: vi.fn(),
|
||||
tracks: [] as unknown[],
|
||||
globalTracks: createDefaultGlobalTracks(),
|
||||
};
|
||||
|
||||
type StoreState = typeof storeState;
|
||||
@@ -104,7 +108,7 @@ vi.mock('../util/projectNameUtil', () => ({
|
||||
isReservedProjectName: vi.fn(() => false),
|
||||
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/region/KGMidiRegion', () => ({ KGMidiRegion: class {} }));
|
||||
vi.mock('../core/track/KGAudioTrack', () => ({ KGAudioTrack: class {} }));
|
||||
@@ -207,7 +211,11 @@ describe('Toolbar settings side-panel behavior', () => {
|
||||
storeState.showKGOnePanel = true;
|
||||
storeState.showEventListPanel = false;
|
||||
storeState.keySignature = 'C major';
|
||||
storeState.playheadPosition = 0;
|
||||
storeState.globalTracks = createDefaultGlobalTracks();
|
||||
storeState.setKeySignature.mockClear();
|
||||
storeState.refreshProjectState.mockClear();
|
||||
executeCommandMock.mockClear();
|
||||
});
|
||||
|
||||
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.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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
FaCog, FaMagnet, FaCut, FaCircle, FaCompress
|
||||
} from 'react-icons/fa';
|
||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||
import { GlobalTrackType } from '../core/global-track';
|
||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
@@ -36,6 +38,7 @@ import PianoIcon from './common/icons/PianoIcon';
|
||||
import MetronomeIcon from './common/icons/MetronomeIcon';
|
||||
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
|
||||
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
|
||||
import { UpdateKeySignatureRegionCommand } from '../core/commands';
|
||||
|
||||
const Toolbar: React.FC = () => {
|
||||
const {
|
||||
@@ -47,6 +50,7 @@ const Toolbar: React.FC = () => {
|
||||
maxBars, setMaxBars,
|
||||
barWidthMultiplier, setBarWidthMultiplier,
|
||||
isLooping, toggleLoop,
|
||||
globalTracks,
|
||||
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
||||
toggleChatBox, toggleSettings, toggleKGOnePanel, toggleEventListPanel, activateSidePanel, showKGOnePanel, showEventListPanel, showChatBox, showSettings, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
|
||||
isRecording, startRecording, stopRecording,
|
||||
@@ -66,6 +70,16 @@ const Toolbar: React.FC = () => {
|
||||
// State for key signature dropdown
|
||||
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
|
||||
const [showExportDropdown, setShowExportDropdown] = React.useState(false);
|
||||
|
||||
@@ -691,10 +705,18 @@ const Toolbar: React.FC = () => {
|
||||
|
||||
const handleKeySignatureChange = (newKeySignature: string) => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("Key signature changed from", keySignature, "to", newKeySignature);
|
||||
console.log("Key signature changed from", displayedKeySignature, "to", newKeySignature);
|
||||
}
|
||||
|
||||
if (activeKeySignatureRegion) {
|
||||
KGCore.instance().executeCommand(new UpdateKeySignatureRegionCommand(
|
||||
activeKeySignatureRegion.getId(),
|
||||
newKeySignature as KeySignature
|
||||
));
|
||||
refreshProjectState();
|
||||
} else {
|
||||
setKeySignature(newKeySignature as KeySignature);
|
||||
}
|
||||
setStatus(`Key signature changed to ${newKeySignature}`);
|
||||
setShowKeySignatureDropdown(false);
|
||||
};
|
||||
@@ -1167,13 +1189,13 @@ const Toolbar: React.FC = () => {
|
||||
onClick={() => setShowKeySignatureDropdown((current) => !current)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={showKeySignatureDropdown}
|
||||
aria-label={`Choose key signature, current ${keySignature}`}
|
||||
aria-label={`Choose key signature, current ${displayedKeySignature}`}
|
||||
>
|
||||
{keySignature}
|
||||
{displayedKeySignature}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<KeySignaturePickerPopup value={keySignature} onChange={handleKeySignatureChange} />
|
||||
<KeySignaturePickerPopup value={displayedKeySignature} onChange={handleKeySignatureChange} />
|
||||
</FloatingPopup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import './FloatingPopup.css';
|
||||
|
||||
type FloatingPopupPlacement = 'bottom' | 'top' | 'left' | 'right';
|
||||
@@ -14,6 +15,7 @@ interface FloatingPopupProps {
|
||||
contentClassName?: string;
|
||||
panelClassName?: string;
|
||||
arrowClassName?: string;
|
||||
renderInPortal?: boolean;
|
||||
}
|
||||
|
||||
const FloatingPopup: React.FC<FloatingPopupProps> = ({
|
||||
@@ -27,8 +29,63 @@ const FloatingPopup: React.FC<FloatingPopupProps> = ({
|
||||
contentClassName = '',
|
||||
panelClassName = '',
|
||||
arrowClassName = '',
|
||||
renderInPortal = false,
|
||||
}) => {
|
||||
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(() => {
|
||||
if (!isOpen) {
|
||||
@@ -56,17 +113,13 @@ const FloatingPopup: React.FC<FloatingPopupProps> = ({
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div className={`floating-popup ${className}`.trim()} ref={rootRef}>
|
||||
<div className={`floating-popup-trigger ${triggerClassName}`.trim()}>
|
||||
{trigger}
|
||||
</div>
|
||||
{isOpen && (
|
||||
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()}
|
||||
@@ -77,7 +130,14 @@ const FloatingPopup: React.FC<FloatingPopupProps> = ({
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={`floating-popup ${className}`.trim()} ref={rootRef}>
|
||||
<div className={`floating-popup-trigger ${triggerClassName}`.trim()} ref={triggerRef}>
|
||||
{trigger}
|
||||
</div>
|
||||
{renderInPortal && popupSurface ? createPortal(popupSurface, document.body) : popupSurface}
|
||||
</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 type { RegionClickOptions } from '../interfaces';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { TOOLBAR_CONSTANTS } from '../../constants';
|
||||
|
||||
interface GlobalMarkerLaneProps {
|
||||
markerRegions: KGMarkerRegion[];
|
||||
maxBars: number;
|
||||
barWidthMultiplier: number;
|
||||
timeSignature: { numerator: number; denominator: number };
|
||||
selectedRegionIds: string[];
|
||||
editingRegionId: string | null;
|
||||
@@ -28,6 +30,7 @@ const DRAG_THRESHOLD_PX = 4;
|
||||
const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({
|
||||
markerRegions,
|
||||
maxBars,
|
||||
barWidthMultiplier,
|
||||
timeSignature,
|
||||
selectedRegionIds,
|
||||
editingRegionId,
|
||||
@@ -57,11 +60,9 @@ const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({
|
||||
|
||||
const totalBeats = maxBars * timeSignature.numerator;
|
||||
const beatWidth = useMemo(() => {
|
||||
const barWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||
) || 40;
|
||||
const barWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
|
||||
return barWidth / timeSignature.numerator;
|
||||
}, [timeSignature.numerator]);
|
||||
}, [barWidthMultiplier, timeSignature.numerator]);
|
||||
|
||||
const clampStartBeat = (value: number) => Math.max(0, Math.min(totalBeats - 1, 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 type { SheetMeasureMetric } from './sheetNotationTypes';
|
||||
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 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', () => ({
|
||||
useProjectStore: (selector: (state: {
|
||||
playheadPosition: number;
|
||||
setPlayheadPosition: typeof setPlayheadPosition;
|
||||
requestMainContentScroll: typeof requestMainContentScroll;
|
||||
}) => unknown) => selector({
|
||||
playheadPosition: 0,
|
||||
setPlayheadPosition,
|
||||
requestMainContentScroll,
|
||||
}),
|
||||
globalTracks: typeof storeState.globalTracks;
|
||||
}) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
@@ -66,6 +75,7 @@ vi.mock('vexflow', () => {
|
||||
}
|
||||
|
||||
addKeySignature() {
|
||||
vexflowMocks.addKeySignatureMock(...arguments);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -143,7 +153,7 @@ vi.mock('vexflow', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
Accidental: { applyAccidentals: vi.fn() },
|
||||
Accidental: { applyAccidentals: vexflowMocks.applyAccidentalsMock },
|
||||
BarlineType: { SINGLE: 1, NONE: 0 },
|
||||
Beam: MockBeam,
|
||||
Dot: { buildAndAttach: vi.fn() },
|
||||
@@ -169,6 +179,9 @@ describe('SheetMusicView', () => {
|
||||
setPlayheadPosition.mockClear();
|
||||
requestMainContentScroll.mockClear();
|
||||
onMetricsChange.mockClear();
|
||||
vexflowMocks.addKeySignatureMock.mockClear();
|
||||
vexflowMocks.applyAccidentalsMock.mockClear();
|
||||
storeState.globalTracks = createDefaultGlobalTracks();
|
||||
});
|
||||
|
||||
it('maps header clicks in region scope without adding scroll offset', () => {
|
||||
@@ -299,4 +312,64 @@ describe('SheetMusicView', () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Accidental, BarlineType, Beam, Dot, Formatter, Renderer, Stave, StaveNo
|
||||
import { Playhead } from '../common';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
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 { InstrumentType } from '../../core/track/KGMidiTrack';
|
||||
import type { SheetMeasureMetric, SheetMeasureModel, SheetQuantization } from './sheetNotationTypes';
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
getSheetBeatAtPixel,
|
||||
buildSheetMeasureModels,
|
||||
getSheetPlayheadPixel,
|
||||
getSheetKeySignatureChangeModifierWidth,
|
||||
projectKeySignatureToVexFlow,
|
||||
resolveDurationSpec,
|
||||
resolveSheetClef,
|
||||
@@ -64,12 +67,28 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
||||
}) => {
|
||||
const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition);
|
||||
const requestMainContentScroll = useProjectStore(state => state.requestMainContentScroll);
|
||||
const globalTracks = useProjectStore(state => state.globalTracks);
|
||||
const [metrics, setMetrics] = useState<SheetMeasureMetric[]>([]);
|
||||
const [tiePaths, setTiePaths] = useState<SheetTiePath[]>([]);
|
||||
const headerRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureHostRefs = useRef<Array<HTMLDivElement | 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(() => (
|
||||
sheetMusicTrackScopeEnabled
|
||||
? 1
|
||||
@@ -88,13 +107,24 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
||||
projectMaxBars: maxBars,
|
||||
timeSignature,
|
||||
quantization,
|
||||
defaultKeySignature: keySignature,
|
||||
resolveKeySignatureAtBar: resolveEffectiveKeySignatureAtBar,
|
||||
});
|
||||
}, [activeRegion, maxBars, midiRegions, quantization, sheetMusicTrackScopeEnabled, timeSignature]);
|
||||
}, [activeRegion, keySignature, maxBars, midiRegions, quantization, resolveEffectiveKeySignatureAtBar, sheetMusicTrackScopeEnabled, timeSignature]);
|
||||
const measureWidths = useMemo(
|
||||
() => measureModels.map((measure, index) => (
|
||||
Math.max(
|
||||
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]
|
||||
@@ -119,6 +149,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
||||
clef,
|
||||
instrument,
|
||||
keySignature,
|
||||
measureKeySignatures: measureModels.map((measure) => measure.keySignature),
|
||||
quantization: quantization.raw,
|
||||
numerator: timeSignature.numerator,
|
||||
denominator: timeSignature.denominator,
|
||||
@@ -157,6 +188,8 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
||||
renderer.resize(width, STAFF_HEIGHT);
|
||||
const context = renderer.getContext();
|
||||
const showLeadingModifiers = index === 0;
|
||||
const measureVexKeySignature = projectKeySignatureToVexFlow(measure.keySignature);
|
||||
const previousMeasureKeySignature = index > 0 ? measureModels[index - 1]?.keySignature : null;
|
||||
const staveX = showLeadingModifiers ? 8 : 0;
|
||||
const staveWidth = Math.max(0, width - staveX);
|
||||
const stave = new Stave(staveX, 10, staveWidth);
|
||||
@@ -164,8 +197,13 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
||||
stave.setEndBarType(BarlineType.SINGLE);
|
||||
if (showLeadingModifiers) {
|
||||
stave.addClef(clef);
|
||||
stave.addKeySignature(vexKeySignature);
|
||||
stave.addKeySignature(measureVexKeySignature);
|
||||
stave.addTimeSignature(`${timeSignature.numerator}/${timeSignature.denominator}`);
|
||||
} else if (measure.keySignature !== previousMeasureKeySignature) {
|
||||
stave.addKeySignature(
|
||||
measureVexKeySignature,
|
||||
previousMeasureKeySignature ? projectKeySignatureToVexFlow(previousMeasureKeySignature) : undefined
|
||||
);
|
||||
}
|
||||
stave.setContext(context).draw();
|
||||
|
||||
@@ -176,7 +214,7 @@ const SheetMusicView: React.FC<SheetMusicViewProps> = ({
|
||||
});
|
||||
voice.setStrict(false);
|
||||
voice.addTickables(notes);
|
||||
Accidental.applyAccidentals([voice], vexKeySignature);
|
||||
Accidental.applyAccidentals([voice], measureVexKeySignature);
|
||||
const beams = Beam.generateBeams(notes.filter(note => !note.isRest()));
|
||||
new Formatter().joinVoices([voice]).formatToStave([voice], stave, { stave });
|
||||
voice.draw(context, stave);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getSheetBeatAtPixel,
|
||||
buildSheetMeasureModels,
|
||||
getSheetPlayheadPixel,
|
||||
getSheetKeySignatureChangeModifierWidth,
|
||||
getSheetQuantizationOptions,
|
||||
isDrumInstrument,
|
||||
parseSheetQuantization,
|
||||
@@ -52,8 +53,8 @@ describe('sheetNotation', () => {
|
||||
|
||||
it('maps playhead position through variable-width bars', () => {
|
||||
const metrics = buildSheetMeasureMetrics([
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] },
|
||||
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] },
|
||||
{ barIndex: 0, absoluteBarIndex: 0, startBeat: 0, endBeat: 4, keySignature: 'C major', events: [] },
|
||||
{ barIndex: 1, absoluteBarIndex: 1, startBeat: 4, endBeat: 8, keySignature: 'C major', events: [] },
|
||||
], [120, 240]);
|
||||
|
||||
expect(getSheetPlayheadPixel(0, metrics)).toBe(0);
|
||||
@@ -74,6 +75,13 @@ describe('sheetNotation', () => {
|
||||
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', () => {
|
||||
const region = createMockMidiRegion({
|
||||
length: 8,
|
||||
@@ -132,11 +140,30 @@ describe('sheetNotation', () => {
|
||||
|
||||
it('maps absolute track beats through sheet metrics for full-track mode', () => {
|
||||
const metrics = buildSheetMeasureMetrics([
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, events: [] },
|
||||
{ barIndex: 1, startBeat: 4, endBeat: 8, events: [] },
|
||||
{ barIndex: 0, absoluteBarIndex: 0, startBeat: 0, endBeat: 4, keySignature: 'C major', events: [] },
|
||||
{ barIndex: 1, absoluteBarIndex: 1, startBeat: 4, endBeat: 8, keySignature: 'C major', events: [] },
|
||||
], [120, 240]);
|
||||
|
||||
expect(getSheetPlayheadPixel(5, metrics)).toBe(180);
|
||||
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 type { KeySignature } from '../../core/KGProject';
|
||||
import type { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
@@ -30,6 +31,8 @@ export interface BuildSheetNotationOptions {
|
||||
projectMaxBars?: number;
|
||||
timeSignature: { numerator: number; denominator: number };
|
||||
quantization: SheetQuantization;
|
||||
defaultKeySignature?: KeySignature;
|
||||
resolveKeySignatureAtBar?: (barIndex: number) => KeySignature;
|
||||
}
|
||||
|
||||
interface WorkingEvent {
|
||||
@@ -54,6 +57,32 @@ export function projectKeySignatureToVexFlow(keySignature: KeySignature): string
|
||||
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 {
|
||||
const [primaryText, subdivisionText] = value.split(',');
|
||||
const primary = Number.parseInt(primaryText, 10);
|
||||
@@ -203,10 +232,13 @@ export function buildSheetMeasureModels({
|
||||
projectMaxBars,
|
||||
timeSignature,
|
||||
quantization,
|
||||
defaultKeySignature = 'C major',
|
||||
resolveKeySignatureAtBar,
|
||||
}: BuildSheetNotationOptions): SheetMeasureModel[] {
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const isTrackScope = scope === 'track';
|
||||
const timelineStartBeat = isTrackScope ? 0 : 0;
|
||||
const regionStartBar = Math.floor(region.getStartFromBeat() / beatsPerBar);
|
||||
const measureCount = isTrackScope
|
||||
? Math.max(1, projectMaxBars ?? 1)
|
||||
: Math.max(1, Math.ceil(region.getLength() / beatsPerBar));
|
||||
@@ -226,8 +258,10 @@ export function buildSheetMeasureModels({
|
||||
|
||||
const measures: SheetMeasureModel[] = Array.from({ length: measureCount }, (_, barIndex) => ({
|
||||
barIndex,
|
||||
absoluteBarIndex: isTrackScope ? barIndex : regionStartBar + barIndex,
|
||||
startBeat: timelineStartBeat + barIndex * beatsPerBar,
|
||||
endBeat: timelineStartBeat + (barIndex + 1) * beatsPerBar,
|
||||
keySignature: resolveKeySignatureAtBar?.(isTrackScope ? barIndex : regionStartBar + barIndex) ?? defaultKeySignature,
|
||||
events: [],
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { KeySignature } from '../../core/KGProject';
|
||||
|
||||
export interface SheetMeasureMetric {
|
||||
barIndex: number;
|
||||
startBeat: number;
|
||||
@@ -24,7 +26,9 @@ export interface SheetDisplayEvent {
|
||||
|
||||
export interface SheetMeasureModel {
|
||||
barIndex: number;
|
||||
absoluteBarIndex: number;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
keySignature: KeySignature;
|
||||
events: SheetDisplayEvent[];
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export class KGProject {
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 13;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 14;
|
||||
|
||||
@Expose()
|
||||
@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}"`;
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,14 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
|
||||
|
||||
// Global region commands
|
||||
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
|
||||
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
|
||||
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
|
||||
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
|
||||
export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignatureRegionCommand';
|
||||
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
|
||||
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
|
||||
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
|
||||
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
|
||||
|
||||
// Note commands
|
||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGGlobalRegion } from '../region/KGGlobalRegion';
|
||||
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
||||
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
||||
|
||||
export enum GlobalTrackType {
|
||||
@@ -32,6 +33,7 @@ export class KGGlobalTrack {
|
||||
subTypes: [
|
||||
{ value: KGGlobalRegion, name: 'KGGlobalRegion' },
|
||||
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
|
||||
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ export class KGSignatureTrack extends KGGlobalTrack {
|
||||
@Expose()
|
||||
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, []);
|
||||
this.__type = 'KGSignatureTrack';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { GlobalTrackType } from '../global-track';
|
||||
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
||||
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
||||
import { KGTrack } from '../track/KGTrack';
|
||||
|
||||
@@ -179,6 +180,25 @@ describe('KGProjectStorage', () => {
|
||||
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 () => {
|
||||
const project = createTestProject('My Song');
|
||||
await storage.save('My Song', project);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { upgradeToV10 } from './upgradeToV10';
|
||||
import { upgradeToV11 } from './upgradeToV11';
|
||||
import { upgradeToV12 } from './upgradeToV12';
|
||||
import { upgradeToV13 } from './upgradeToV13';
|
||||
import { upgradeToV14 } from './upgradeToV14';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
break;
|
||||
}
|
||||
case 14: {
|
||||
workingProject = upgradeToV14(workingProject);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('upgradeToV13', () => {
|
||||
GlobalTrackType.Signature,
|
||||
GlobalTrackType.Chord,
|
||||
]);
|
||||
expect(project.getGlobalTracks()[2].getName()).toBe('Key Signature');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { KGProject } from '../core/KGProject';
|
||||
import type { KeySignature } from '../core/KGProject';
|
||||
import {
|
||||
GlobalTrackType,
|
||||
KGGlobalTrack,
|
||||
createDefaultGlobalTracks,
|
||||
} from '../core/global-track';
|
||||
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||
|
||||
export const DEFAULT_MARKER_REGION_NAME = 'Marker';
|
||||
|
||||
@@ -83,3 +85,55 @@ export function findMarkerNeighborBounds(
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user