diff --git a/src/components/MainContent.css b/src/components/MainContent.css index df06203..78a5653 100644 --- a/src/components/MainContent.css +++ b/src/components/MainContent.css @@ -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; diff --git a/src/components/MainContent.test.tsx b/src/components/MainContent.test.tsx index 5da74cf..c7d2a02 100644 --- a/src/components/MainContent.test.tsx +++ b/src/components/MainContent.test.tsx @@ -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(); 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(); + + 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); }); }); diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index ef64af6..78ea65c 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -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 = ({ const [animateGlobalTracksMock, setAnimateGlobalTracksMock] = useState(false); const [editingGlobalRegionId, setEditingGlobalRegionId] = useState(null); const [editingGlobalRegionText, setEditingGlobalRegionText] = useState(''); + const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState(null); // Use the region operations hook const { deleteSelectedRegions } = useRegionOperations({ @@ -162,6 +171,8 @@ const MainContent: React.FC = ({ 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 = ({ } try { - KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(selectedGlobalRegionIds)); + const signatureRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGKeySignatureRegion); + const markerRegionIds = selectedGlobalRegionIds.filter((regionId) => findProjectRegionById(regionId) instanceof KGMarkerRegion); + + if (signatureRegionIds.length > 0 && markerRegionIds.length === 0) { + KGCore.instance().executeCommand( + signatureRegionIds.length === 1 + ? new DeleteKeySignatureRegionCommand(signatureRegionIds[0]) + : new DeleteMultipleKeySignatureRegionsCommand(signatureRegionIds) + ); + } else if (markerRegionIds.length > 0 && signatureRegionIds.length === 0) { + KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(markerRegionIds)); + } else { + KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(selectedGlobalRegionIds)); + } + if (editingGlobalRegionId && selectedGlobalRegionIds.includes(editingGlobalRegionId)) { 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 = ({ 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 = ({ } }, [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 = ({ e.stopPropagation(); if (track.id === 'marker') { createMarkerAtPlayheadBar(); + return; + } + + if (track.id === 'signature') { + createKeySignatureAtPlayheadBar(); } }} > @@ -1323,6 +1416,7 @@ const MainContent: React.FC = ({ key={track.id} markerRegions={markerRegions} maxBars={maxBars} + barWidthMultiplier={barWidthMultiplier} timeSignature={timeSignature} selectedRegionIds={selectedRegionIds} editingRegionId={editingGlobalRegionId} @@ -1339,6 +1433,22 @@ const MainContent: React.FC = ({ onMoveRegion={moveGlobalMarkerRegion} onResizeRegion={resizeGlobalMarkerRegion} /> + ) : track.id === 'signature' ? ( + setEditingKeySignatureRegionId(null)} + onSelectRegion={selectGlobalRegion} + onCreateAtBar={createKeySignatureAtBar} + onResizeRegion={resizeKeySignatureRegion} + onChangeKeySignature={updateKeySignatureRegion} + onOpenPicker={beginEditingKeySignatureRegion} + /> ) : (
({ 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(); + + 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'); + }); }); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 00bd63c..99a28cd 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -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); } - setKeySignature(newKeySignature as KeySignature); + if (activeKeySignatureRegion) { + KGCore.instance().executeCommand(new UpdateKeySignatureRegionCommand( + activeKeySignatureRegion.getId(), + newKeySignature as KeySignature + )); + refreshProjectState(); + } else { + setKeySignature(newKeySignature as KeySignature); + } setStatus(`Key signature changed to ${newKeySignature}`); 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} )} > - +
diff --git a/src/components/common/FloatingPopup.tsx b/src/components/common/FloatingPopup.tsx index c746d8b..0e98476 100644 --- a/src/components/common/FloatingPopup.tsx +++ b/src/components/common/FloatingPopup.tsx @@ -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 = ({ @@ -27,8 +29,63 @@ const FloatingPopup: React.FC = ({ contentClassName = '', panelClassName = '', arrowClassName = '', + renderInPortal = false, }) => { const rootRef = React.useRef(null); + const triggerRef = React.useRef(null); + const [portalStyle, setPortalStyle] = React.useState(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,28 +113,31 @@ const FloatingPopup: React.FC = ({ }; }, [isOpen, onClose]); + const popupSurface = isOpen ? ( +
+ + ) : null; + return (
-
+
{trigger}
- {isOpen && ( -
- - )} + {renderInPortal && popupSurface ? createPortal(popupSurface, document.body) : popupSurface}
); }; diff --git a/src/components/global-track/GlobalKeySignatureLane.test.tsx b/src/components/global-track/GlobalKeySignatureLane.test.tsx new file mode 100644 index 0000000..d39353c --- /dev/null +++ b/src/components/global-track/GlobalKeySignatureLane.test.tsx @@ -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( + + ); + + 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( + + ); + + 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); + }); +}); diff --git a/src/components/global-track/GlobalKeySignatureLane.tsx b/src/components/global-track/GlobalKeySignatureLane.tsx new file mode 100644 index 0000000..66a48a6 --- /dev/null +++ b/src/components/global-track/GlobalKeySignatureLane.tsx @@ -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 = ({ + signatureRegions, + maxBars, + barWidthMultiplier, + timeSignature, + selectedRegionIds, + pickerRegionId, + onClosePicker, + onSelectRegion, + onCreateAtBar, + onResizeRegion, + onChangeKeySignature, + onOpenPicker, +}) => { + const laneRef = useRef(null); + const [previewBars, setPreviewBars] = useState>({}); + const [hoverEdges, setHoverEdges] = useState>({}); + 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): 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) => { + 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) => { + if (!(event.target instanceof HTMLElement) || event.target.closest('.global-key-signature-region')) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onCreateAtBar(getCreateBarFromClientX(event.clientX)); + }; + + return ( + <> +
+ {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 ( +
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()); + }} + > + {region.getKeySignature()} + +
+ ); + })} +
+ + ); +}; + +export default GlobalKeySignatureLane; diff --git a/src/components/global-track/GlobalMarkerLane.tsx b/src/components/global-track/GlobalMarkerLane.tsx index e85d30f..2a4d837 100644 --- a/src/components/global-track/GlobalMarkerLane.tsx +++ b/src/components/global-track/GlobalMarkerLane.tsx @@ -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 = ({ markerRegions, maxBars, + barWidthMultiplier, timeSignature, selectedRegionIds, editingRegionId, @@ -57,11 +60,9 @@ const GlobalMarkerLane: React.FC = ({ 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)); diff --git a/src/components/piano-roll/SheetMusicView.test.tsx b/src/components/piano-roll/SheetMusicView.test.tsx index 789073a..eda0754 100644 --- a/src/components/piano-roll/SheetMusicView.test.tsx +++ b/src/components/piano-roll/SheetMusicView.test.tsx @@ -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( + + ); + + 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( + + ); + + const metrics = getLatestMetrics(); + expect(metrics[1].widthPx).toBeGreaterThan(metrics[2].widthPx); + }); }); diff --git a/src/components/piano-roll/SheetMusicView.tsx b/src/components/piano-roll/SheetMusicView.tsx index 8329ba0..b4cc636 100644 --- a/src/components/piano-roll/SheetMusicView.tsx +++ b/src/components/piano-roll/SheetMusicView.tsx @@ -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 = ({ }) => { const setPlayheadPosition = useProjectStore(state => state.setPlayheadPosition); const requestMainContentScroll = useProjectStore(state => state.requestMainContentScroll); + const globalTracks = useProjectStore(state => state.globalTracks); const [metrics, setMetrics] = useState([]); const [tiePaths, setTiePaths] = useState([]); const headerRef = useRef(null); const measureHostRefs = useRef>([]); const lastDrawSignatureRef = useRef(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ }); 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); diff --git a/src/components/piano-roll/sheetNotation.test.ts b/src/components/piano-roll/sheetNotation.test.ts index 36a6f78..78c5a4b 100644 --- a/src/components/piano-roll/sheetNotation.test.ts +++ b/src/components/piano-roll/sheetNotation.test.ts @@ -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']); + }); }); diff --git a/src/components/piano-roll/sheetNotation.ts b/src/components/piano-roll/sheetNotation.ts index 8c6a5ed..ec66440 100644 --- a/src/components/piano-roll/sheetNotation.ts +++ b/src/components/piano-roll/sheetNotation.ts @@ -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: [], })); diff --git a/src/components/piano-roll/sheetNotationTypes.ts b/src/components/piano-roll/sheetNotationTypes.ts index 9638efe..5410e1c 100644 --- a/src/components/piano-roll/sheetNotationTypes.ts +++ b/src/components/piano-roll/sheetNotationTypes.ts @@ -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[]; } diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index 8d3600a..bb0b2d2 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -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, { diff --git a/src/core/commands/global-region/CreateKeySignatureRegionCommand.ts b/src/core/commands/global-region/CreateKeySignatureRegionCommand.ts new file mode 100644 index 0000000..f9d3f75 --- /dev/null +++ b/src/core/commands/global-region/CreateKeySignatureRegionCommand.ts @@ -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; + } +} diff --git a/src/core/commands/global-region/DeleteKeySignatureRegionCommand.ts b/src/core/commands/global-region/DeleteKeySignatureRegionCommand.ts new file mode 100644 index 0000000..b6c1c51 --- /dev/null +++ b/src/core/commands/global-region/DeleteKeySignatureRegionCommand.ts @@ -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`; + } +} diff --git a/src/core/commands/global-region/GlobalKeySignatureCommands.test.ts b/src/core/commands/global-region/GlobalKeySignatureCommands.test.ts new file mode 100644 index 0000000..357b6fe --- /dev/null +++ b/src/core/commands/global-region/GlobalKeySignatureCommands.test.ts @@ -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; + }; + + 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'); + }); +}); diff --git a/src/core/commands/global-region/ResizeKeySignatureRegionCommand.ts b/src/core/commands/global-region/ResizeKeySignatureRegionCommand.ts new file mode 100644 index 0000000..7642c1b --- /dev/null +++ b/src/core/commands/global-region/ResizeKeySignatureRegionCommand.ts @@ -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}"`; + } +} diff --git a/src/core/commands/global-region/UpdateKeySignatureRegionCommand.ts b/src/core/commands/global-region/UpdateKeySignatureRegionCommand.ts new file mode 100644 index 0000000..9b6e9c9 --- /dev/null +++ b/src/core/commands/global-region/UpdateKeySignatureRegionCommand.ts @@ -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}"`; + } +} diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index 1136188..8943933 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -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'; diff --git a/src/core/global-track/KGGlobalTrack.ts b/src/core/global-track/KGGlobalTrack.ts index 4838feb..5a6834a 100644 --- a/src/core/global-track/KGGlobalTrack.ts +++ b/src/core/global-track/KGGlobalTrack.ts @@ -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' }, ], }, }) diff --git a/src/core/global-track/KGSignatureTrack.ts b/src/core/global-track/KGSignatureTrack.ts index bf2dbb5..5378398 100644 --- a/src/core/global-track/KGSignatureTrack.ts +++ b/src/core/global-track/KGSignatureTrack.ts @@ -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'; } diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts index e0df5dd..17c1fc0 100644 --- a/src/core/io/KGProjectStorage.test.ts +++ b/src/core/io/KGProjectStorage.test.ts @@ -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); diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index 54286ce..47311b7 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -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}`); diff --git a/src/core/project-upgrader/upgradeToV13.test.ts b/src/core/project-upgrader/upgradeToV13.test.ts index 086bbb1..fd79615 100644 --- a/src/core/project-upgrader/upgradeToV13.test.ts +++ b/src/core/project-upgrader/upgradeToV13.test.ts @@ -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', () => { diff --git a/src/core/project-upgrader/upgradeToV14.test.ts b/src/core/project-upgrader/upgradeToV14.test.ts new file mode 100644 index 0000000..c407e22 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV14.test.ts @@ -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); + }); +}); diff --git a/src/core/project-upgrader/upgradeToV14.ts b/src/core/project-upgrader/upgradeToV14.ts new file mode 100644 index 0000000..8bcce64 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV14.ts @@ -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; +} diff --git a/src/core/region/KGKeySignatureRegion.ts b/src/core/region/KGKeySignatureRegion.ts new file mode 100644 index 0000000..287264c --- /dev/null +++ b/src/core/region/KGKeySignatureRegion.ts @@ -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'; + } +} diff --git a/src/util/globalTrackUtil.ts b/src/util/globalTrackUtil.ts index 3a38a0d..69f3b9f 100644 --- a/src/util/globalTrackUtil.ts +++ b/src/util/globalTrackUtil.ts @@ -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)); +}