feat: implemented global marker track
This commit is contained in:
@@ -287,6 +287,81 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-marker-lane {
|
||||||
|
position: relative;
|
||||||
|
height: var(--global-track-height);
|
||||||
|
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width));
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
background-color: #2d2d2d;
|
||||||
|
background-size: var(--track-grid-bar-width) var(--global-track-height);
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent calc(var(--track-grid-bar-width) - 1px),
|
||||||
|
#3a3a3a calc(var(--track-grid-bar-width) - 1px),
|
||||||
|
#3a3a3a var(--track-grid-bar-width)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-marker-lane.pencil-cursor {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-marker-region {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
height: calc(var(--global-track-height) - 4px);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #2d2d2d;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
transition: box-shadow 0.1s ease, border-color 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-marker-region:hover {
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-marker-region.selected {
|
||||||
|
border-color: #ffffff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-marker-region:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-marker-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-marker-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.info-container {
|
.info-container {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import MainContent from './MainContent';
|
|||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
|
import { createDefaultGlobalTracks } from '../core/global-track';
|
||||||
import { createMockMidiTrack } from '../test/utils/mock-data';
|
import { createMockMidiTrack } from '../test/utils/mock-data';
|
||||||
|
|
||||||
const midiRegion = new KGMidiRegion('region-1', '1', 0, 'Region 1', 0, 4);
|
const midiRegion = new KGMidiRegion('region-1', '1', 0, 'Region 1', 0, 4);
|
||||||
@@ -17,6 +18,7 @@ audioTrack.setRegions([audioRegion]);
|
|||||||
|
|
||||||
const storeState = {
|
const storeState = {
|
||||||
tracks: [midiTrack, audioTrack],
|
tracks: [midiTrack, audioTrack],
|
||||||
|
globalTracks: createDefaultGlobalTracks(),
|
||||||
maxBars: 8,
|
maxBars: 8,
|
||||||
barWidthMultiplier: 1,
|
barWidthMultiplier: 1,
|
||||||
reorderTracks: vi.fn(),
|
reorderTracks: vi.fn(),
|
||||||
@@ -328,19 +330,18 @@ describe('MainContent', () => {
|
|||||||
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders visual-only add buttons for mock global tracks without mutating track state', () => {
|
it('keeps the non-marker global track add buttons as visual-only controls', () => {
|
||||||
render(<MainContent />);
|
render(<MainContent />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||||
|
|
||||||
const addButtons = [
|
const addButtons = [
|
||||||
screen.getByRole('button', { name: 'Add Marker global track item' }),
|
|
||||||
screen.getByRole('button', { name: 'Add Tempo global track item' }),
|
screen.getByRole('button', { name: 'Add Tempo global track item' }),
|
||||||
screen.getByRole('button', { name: 'Add Signature global track item' }),
|
screen.getByRole('button', { name: 'Add Signature global track item' }),
|
||||||
screen.getByRole('button', { name: 'Add Chord global track item' }),
|
screen.getByRole('button', { name: 'Add Chord global track item' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(addButtons).toHaveLength(4);
|
expect(addButtons).toHaveLength(3);
|
||||||
|
|
||||||
addButtons.forEach(button => {
|
addButtons.forEach(button => {
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
|
|||||||
+224
-28
@@ -3,12 +3,16 @@ import './MainContent.css';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
|
import { GlobalTrackType } from '../core/global-track';
|
||||||
import { KGTrack } from '../core/track/KGTrack';
|
import { KGTrack } from '../core/track/KGTrack';
|
||||||
import { KGRegion } from '../core/region/KGRegion';
|
import { KGRegion } from '../core/region/KGRegion';
|
||||||
|
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||||
|
import { KGMarkerRegion } from '../core/region/KGMarkerRegion';
|
||||||
import TrackInfoPanel from './track/TrackInfoPanel';
|
import TrackInfoPanel from './track/TrackInfoPanel';
|
||||||
import TrackGridPanel from './track/TrackGridPanel';
|
import TrackGridPanel from './track/TrackGridPanel';
|
||||||
|
import GlobalMarkerLane from './global-track/GlobalMarkerLane';
|
||||||
import PianoRoll from './piano-roll/PianoRoll';
|
import PianoRoll from './piano-roll/PianoRoll';
|
||||||
import { TrackCreateDialog } from './common';
|
import { TrackCreateDialog } from './common';
|
||||||
import type { RegionClickOptions, RegionUI } from './interfaces';
|
import type { RegionClickOptions, RegionUI } from './interfaces';
|
||||||
@@ -16,8 +20,16 @@ import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS, TOOLBAR_CONSTANTS } from '../constan
|
|||||||
import { useRegionOperations } from '../hooks/useRegionOperations';
|
import { useRegionOperations } from '../hooks/useRegionOperations';
|
||||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
import { ChangeLoopSettingsCommand } from '../core/commands';
|
import {
|
||||||
import { DeleteTrackAutomationPointsCommand } from '../core/commands';
|
ChangeLoopSettingsCommand,
|
||||||
|
CreateGlobalMarkerRegionCommand,
|
||||||
|
DeleteMultipleGlobalRegionsCommand,
|
||||||
|
DeleteTrackAutomationPointsCommand,
|
||||||
|
MoveGlobalRegionCommand,
|
||||||
|
ResizeGlobalRegionCommand,
|
||||||
|
UpdateGlobalRegionTextCommand,
|
||||||
|
} from '../core/commands';
|
||||||
|
import { DEFAULT_MARKER_REGION_NAME } from '../util/globalTrackUtil';
|
||||||
import { FaPlus } from 'react-icons/fa';
|
import { FaPlus } from 'react-icons/fa';
|
||||||
import { FaSquareArrowUpRight } from 'react-icons/fa6';
|
import { FaSquareArrowUpRight } from 'react-icons/fa6';
|
||||||
|
|
||||||
@@ -42,6 +54,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
tracks,
|
tracks,
|
||||||
|
globalTracks,
|
||||||
maxBars,
|
maxBars,
|
||||||
barWidthMultiplier,
|
barWidthMultiplier,
|
||||||
reorderTracks,
|
reorderTracks,
|
||||||
@@ -95,6 +108,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
const [showGlobalTracksMock, setShowGlobalTracksMock] = useState(false);
|
const [showGlobalTracksMock, setShowGlobalTracksMock] = useState(false);
|
||||||
const [renderGlobalTracksMock, setRenderGlobalTracksMock] = useState(false);
|
const [renderGlobalTracksMock, setRenderGlobalTracksMock] = useState(false);
|
||||||
const [animateGlobalTracksMock, setAnimateGlobalTracksMock] = useState(false);
|
const [animateGlobalTracksMock, setAnimateGlobalTracksMock] = useState(false);
|
||||||
|
const [editingGlobalRegionId, setEditingGlobalRegionId] = useState<string | null>(null);
|
||||||
|
const [editingGlobalRegionText, setEditingGlobalRegionText] = useState('');
|
||||||
|
|
||||||
// Use the region operations hook
|
// Use the region operations hook
|
||||||
const { deleteSelectedRegions } = useRegionOperations({
|
const { deleteSelectedRegions } = useRegionOperations({
|
||||||
@@ -143,9 +158,60 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
refreshProjectState,
|
refreshProjectState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const markerTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Marker) ?? null;
|
||||||
|
const markerRegions = (markerTrack?.getRegions() ?? []).filter(
|
||||||
|
(region): region is KGMarkerRegion => region instanceof KGMarkerRegion
|
||||||
|
);
|
||||||
|
|
||||||
|
const findProjectRegionById = useCallback((regionId: string): KGRegion | null => {
|
||||||
|
for (const track of tracks) {
|
||||||
|
const region = track.getRegions().find(candidate => candidate.getId() === regionId);
|
||||||
|
if (region) {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const globalTrack of globalTracks) {
|
||||||
|
const region = globalTrack.getRegions().find(candidate => candidate.getId() === regionId);
|
||||||
|
if (region) {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [globalTracks, tracks]);
|
||||||
|
|
||||||
|
const isGlobalRegionId = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
return region instanceof KGGlobalRegion;
|
||||||
|
}, [findProjectRegionById]);
|
||||||
|
|
||||||
|
const deleteSelectedGlobalRegions = useCallback((): boolean => {
|
||||||
|
const selectedGlobalRegionIds = selectedRegionIds.filter(regionId => isGlobalRegionId(regionId));
|
||||||
|
if (selectedGlobalRegionIds.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new DeleteMultipleGlobalRegionsCommand(selectedGlobalRegionIds));
|
||||||
|
if (editingGlobalRegionId && selectedGlobalRegionIds.includes(editingGlobalRegionId)) {
|
||||||
|
setEditingGlobalRegionId(null);
|
||||||
|
setEditingGlobalRegionText('');
|
||||||
|
}
|
||||||
|
refreshProjectState();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting global marker regions:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [editingGlobalRegionId, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
||||||
|
|
||||||
// Register the delete function with the global manager
|
// Register the delete function with the global manager
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
regionDeleteManager.registerDeleteCallback(() => {
|
regionDeleteManager.registerDeleteCallback(() => {
|
||||||
|
if (deleteSelectedGlobalRegions()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (deleteSelectedTrackAutomationPoints()) {
|
if (deleteSelectedTrackAutomationPoints()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -156,7 +222,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
regionDeleteManager.unregisterDeleteCallback();
|
regionDeleteManager.unregisterDeleteCallback();
|
||||||
};
|
};
|
||||||
}, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints]);
|
}, [deleteSelectedGlobalRegions, deleteSelectedRegions, deleteSelectedTrackAutomationPoints]);
|
||||||
|
|
||||||
// Refs to track pending updates for verification
|
// Refs to track pending updates for verification
|
||||||
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
||||||
@@ -594,26 +660,21 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
// Helper function to select a region (clears previous selections)
|
// Helper function to select a region (clears previous selections)
|
||||||
const applyRegionSelection = (orderedSelectionIds: string[]) => {
|
const applyRegionSelection = (orderedSelectionIds: string[]) => {
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
|
const allRegions = [
|
||||||
|
...tracks.flatMap(projectTrack => projectTrack.getRegions()),
|
||||||
|
...globalTracks.flatMap(globalTrack => globalTrack.getRegions()),
|
||||||
|
];
|
||||||
|
|
||||||
tracks.forEach(projectTrack => {
|
allRegions.forEach(projectRegion => projectRegion.deselect());
|
||||||
projectTrack.getRegions().forEach(projectRegion => projectRegion.deselect());
|
|
||||||
});
|
|
||||||
|
|
||||||
clearAllSelections();
|
clearAllSelections();
|
||||||
|
|
||||||
const selectedRegions: KGRegion[] = orderedSelectionIds
|
const selectedRegions: KGRegion[] = orderedSelectionIds
|
||||||
.map(selectedId => {
|
.map(selectedId => allRegions.find(region => region.getId() === selectedId) ?? null)
|
||||||
for (const projectTrack of tracks) {
|
|
||||||
const selectedRegion = projectTrack.getRegions().find(r => r.getId() === selectedId);
|
|
||||||
if (selectedRegion) {
|
|
||||||
selectedRegion.select();
|
|
||||||
return selectedRegion as KGRegion;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((selectedRegion): selectedRegion is KGRegion => selectedRegion !== null);
|
.filter((selectedRegion): selectedRegion is KGRegion => selectedRegion !== null);
|
||||||
|
|
||||||
|
selectedRegions.forEach(selectedRegion => selectedRegion.select());
|
||||||
|
|
||||||
if (selectedRegions.length > 0) {
|
if (selectedRegions.length > 0) {
|
||||||
core.addSelectedItems(selectedRegions);
|
core.addSelectedItems(selectedRegions);
|
||||||
}
|
}
|
||||||
@@ -621,9 +682,12 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
const lastSelectedRegionId = selectedRegions.length > 0
|
const lastSelectedRegionId = selectedRegions.length > 0
|
||||||
? selectedRegions[selectedRegions.length - 1].getId()
|
? selectedRegions[selectedRegions.length - 1].getId()
|
||||||
: null;
|
: null;
|
||||||
|
const lastSelectedRegion = selectedRegions.length > 0
|
||||||
|
? selectedRegions[selectedRegions.length - 1]
|
||||||
|
: null;
|
||||||
|
|
||||||
setSelectedRegionId(lastSelectedRegionId);
|
setSelectedRegionId(lastSelectedRegionId);
|
||||||
if (lastSelectedRegionId) {
|
if (lastSelectedRegionId && lastSelectedRegion && !(lastSelectedRegion instanceof KGGlobalRegion)) {
|
||||||
setActiveRegionId(lastSelectedRegionId);
|
setActiveRegionId(lastSelectedRegionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,7 +703,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastSelectedRegion = selectedRegions[selectedRegions.length - 1];
|
if (!lastSelectedRegion || lastSelectedRegion instanceof KGGlobalRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (lastSelectedRegion instanceof KGAudioRegion) {
|
if (lastSelectedRegion instanceof KGAudioRegion) {
|
||||||
openSpectrogramViewer(lastSelectedRegionId);
|
openSpectrogramViewer(lastSelectedRegionId);
|
||||||
} else if (lastSelectedRegion instanceof KGMidiRegion) {
|
} else if (lastSelectedRegion instanceof KGMidiRegion) {
|
||||||
@@ -677,10 +744,22 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId));
|
||||||
const orderedSelection = options.shiftKey
|
const orderedSelection = options.shiftKey
|
||||||
? (selectedRegionIds.includes(regionId)
|
? (selectedRegionIds.includes(regionId)
|
||||||
? selectedRegionIds.filter(id => id !== regionId)
|
? regularSelectedRegionIds.filter(id => id !== regionId)
|
||||||
: [...selectedRegionIds, regionId])
|
: [...regularSelectedRegionIds, regionId])
|
||||||
|
: [regionId];
|
||||||
|
|
||||||
|
applyRegionSelection(orderedSelection);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectGlobalRegion = (regionId: string, options: RegionClickOptions = { shiftKey: false }) => {
|
||||||
|
const globalSelectedRegionIds = selectedRegionIds.filter(selectedId => isGlobalRegionId(selectedId));
|
||||||
|
const orderedSelection = options.shiftKey
|
||||||
|
? (globalSelectedRegionIds.includes(regionId)
|
||||||
|
? globalSelectedRegionIds.filter(id => id !== regionId)
|
||||||
|
: [...globalSelectedRegionIds, regionId])
|
||||||
: [regionId];
|
: [regionId];
|
||||||
|
|
||||||
applyRegionSelection(orderedSelection);
|
applyRegionSelection(orderedSelection);
|
||||||
@@ -688,13 +767,14 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
|
|
||||||
const handleRegionLassoSelection = (regionIds: string[], options: RegionClickOptions = { shiftKey: false }) => {
|
const handleRegionLassoSelection = (regionIds: string[], options: RegionClickOptions = { shiftKey: false }) => {
|
||||||
const orderedRegionIds = regionIds.filter(regionId => regions.some(region => region.id === regionId));
|
const orderedRegionIds = regionIds.filter(regionId => regions.some(region => region.id === regionId));
|
||||||
|
const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId));
|
||||||
const orderedSelection = options.shiftKey
|
const orderedSelection = options.shiftKey
|
||||||
? orderedRegionIds.reduce<string[]>((nextSelection, regionId) => {
|
? orderedRegionIds.reduce<string[]>((nextSelection, regionId) => {
|
||||||
if (nextSelection.includes(regionId)) {
|
if (nextSelection.includes(regionId)) {
|
||||||
return nextSelection.filter(id => id !== regionId);
|
return nextSelection.filter(id => id !== regionId);
|
||||||
}
|
}
|
||||||
return [...nextSelection, regionId];
|
return [...nextSelection, regionId];
|
||||||
}, [...selectedRegionIds])
|
}, [...regularSelectedRegionIds])
|
||||||
: orderedRegionIds;
|
: orderedRegionIds;
|
||||||
|
|
||||||
applyRegionSelection(orderedSelection);
|
applyRegionSelection(orderedSelection);
|
||||||
@@ -787,6 +867,95 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setShowCreateTrackModal(true);
|
setShowCreateTrackModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const beginEditingGlobalRegion = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (!(region instanceof KGMarkerRegion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingGlobalRegionId(regionId);
|
||||||
|
setEditingGlobalRegionText(region.getName());
|
||||||
|
}, [findProjectRegionById]);
|
||||||
|
|
||||||
|
const commitGlobalRegionEdit = useCallback((regionId: string) => {
|
||||||
|
const region = findProjectRegionById(regionId);
|
||||||
|
if (!(region instanceof KGMarkerRegion)) {
|
||||||
|
setEditingGlobalRegionId(null);
|
||||||
|
setEditingGlobalRegionText('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedText = editingGlobalRegionText.replace(/\r?\n/g, ' ').trim();
|
||||||
|
setEditingGlobalRegionId(null);
|
||||||
|
setEditingGlobalRegionText('');
|
||||||
|
|
||||||
|
if (!trimmedText || trimmedText === region.getName()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new UpdateGlobalRegionTextCommand(regionId, trimmedText));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating marker text:', error);
|
||||||
|
}
|
||||||
|
}, [editingGlobalRegionText, findProjectRegionById, refreshProjectState]);
|
||||||
|
|
||||||
|
const createMarkerAtBeat = useCallback((requestedStartBeat: number) => {
|
||||||
|
const normalizedStartBeat = Math.max(0, Math.round(requestedStartBeat));
|
||||||
|
const occupiedRegion = markerRegions.find(region => region.getStartFromBeat() === normalizedStartBeat);
|
||||||
|
if (occupiedRegion) {
|
||||||
|
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
|
||||||
|
beginEditingGlobalRegion(occupiedRegion.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = new CreateGlobalMarkerRegionCommand(
|
||||||
|
normalizedStartBeat,
|
||||||
|
8 * timeSignature.numerator,
|
||||||
|
DEFAULT_MARKER_REGION_NAME
|
||||||
|
);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
refreshProjectState();
|
||||||
|
|
||||||
|
const createdRegion = command.getCreatedRegion();
|
||||||
|
if (!createdRegion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
|
||||||
|
setEditingGlobalRegionId(createdRegion.getId());
|
||||||
|
setEditingGlobalRegionText(createdRegion.getName());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating marker region:', error);
|
||||||
|
}
|
||||||
|
}, [beginEditingGlobalRegion, markerRegions, refreshProjectState, selectGlobalRegion, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const createMarkerAtPlayheadBar = useCallback(() => {
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const startBeat = Math.floor(playheadPosition / beatsPerBar) * beatsPerBar;
|
||||||
|
createMarkerAtBeat(startBeat);
|
||||||
|
}, [createMarkerAtBeat, playheadPosition, timeSignature.numerator]);
|
||||||
|
|
||||||
|
const moveGlobalMarkerRegion = useCallback((regionId: string, startBeat: number) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new MoveGlobalRegionCommand(regionId, Math.round(startBeat)));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error moving marker region:', error);
|
||||||
|
}
|
||||||
|
}, [refreshProjectState]);
|
||||||
|
|
||||||
|
const resizeGlobalMarkerRegion = useCallback((regionId: string, edge: 'start' | 'end', beat: number) => {
|
||||||
|
try {
|
||||||
|
KGCore.instance().executeCommand(new ResizeGlobalRegionCommand(regionId, edge, Math.round(beat)));
|
||||||
|
refreshProjectState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resizing marker region:', error);
|
||||||
|
}
|
||||||
|
}, [refreshProjectState]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add keyboard event listener for region deletion
|
* Add keyboard event listener for region deletion
|
||||||
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
||||||
@@ -811,9 +980,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
// Only handle if we're not in the piano roll (piano roll has its own delete handler)
|
// Only handle if we're not in the piano roll (piano roll has its own delete handler)
|
||||||
const isInPianoRoll = document.querySelector('.piano-roll')?.contains(event.target as Node);
|
const isInPianoRoll = document.querySelector('.piano-roll')?.contains(event.target as Node);
|
||||||
const isPianoRollOpen = showPianoRoll;
|
const isPianoRollOpen = showPianoRoll;
|
||||||
|
const hasSelectedGlobalRegions = selectedRegionIds.some(regionId => isGlobalRegionId(regionId));
|
||||||
|
|
||||||
if (!isInPianoRoll && !isPianoRollOpen) {
|
if (!isInPianoRoll && (!isPianoRollOpen || hasSelectedGlobalRegions)) {
|
||||||
const deleted = deleteSelectedTrackAutomationPoints() || deleteSelectedRegions();
|
const deleted = deleteSelectedGlobalRegions() || deleteSelectedTrackAutomationPoints() || deleteSelectedRegions();
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
// Prevent default behavior only if regions were actually deleted
|
// Prevent default behavior only if regions were actually deleted
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -829,7 +999,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints, showPianoRoll]); // Dependencies for the effect
|
}, [deleteSelectedGlobalRegions, deleteSelectedRegions, deleteSelectedTrackAutomationPoints, showPianoRoll]); // Dependencies for the effect
|
||||||
|
|
||||||
// Utility function to calculate playhead position from mouse coordinates (bar-level snapping)
|
// Utility function to calculate playhead position from mouse coordinates (bar-level snapping)
|
||||||
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
||||||
@@ -1134,6 +1304,9 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (track.id === 'marker') {
|
||||||
|
createMarkerAtPlayheadBar();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaPlus />
|
<FaPlus />
|
||||||
@@ -1145,10 +1318,33 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
<div className={`global-tracks-grid-shell${animateGlobalTracksMock ? ' expanded' : ' collapsed'}`}>
|
<div className={`global-tracks-grid-shell${animateGlobalTracksMock ? ' expanded' : ' collapsed'}`}>
|
||||||
<div className="global-tracks-grid" aria-hidden="true">
|
<div className="global-tracks-grid" aria-hidden="true">
|
||||||
{GLOBAL_TRACKS.map(track => (
|
{GLOBAL_TRACKS.map(track => (
|
||||||
<div
|
track.id === 'marker' ? (
|
||||||
key={track.id}
|
<GlobalMarkerLane
|
||||||
className="global-track-grid-row"
|
key={track.id}
|
||||||
/>
|
markerRegions={markerRegions}
|
||||||
|
maxBars={maxBars}
|
||||||
|
timeSignature={timeSignature}
|
||||||
|
selectedRegionIds={selectedRegionIds}
|
||||||
|
editingRegionId={editingGlobalRegionId}
|
||||||
|
editingText={editingGlobalRegionText}
|
||||||
|
onEditingTextChange={setEditingGlobalRegionText}
|
||||||
|
onCommitEdit={commitGlobalRegionEdit}
|
||||||
|
onCancelEdit={() => {
|
||||||
|
setEditingGlobalRegionId(null);
|
||||||
|
setEditingGlobalRegionText('');
|
||||||
|
}}
|
||||||
|
onBeginEdit={beginEditingGlobalRegion}
|
||||||
|
onSelectRegion={selectGlobalRegion}
|
||||||
|
onCreateAtBeat={createMarkerAtBeat}
|
||||||
|
onMoveRegion={moveGlobalMarkerRegion}
|
||||||
|
onResizeRegion={resizeGlobalMarkerRegion}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className="global-track-grid-row"
|
||||||
|
/>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
|
||||||
|
import type { RegionClickOptions } from '../interfaces';
|
||||||
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
|
|
||||||
|
interface GlobalMarkerLaneProps {
|
||||||
|
markerRegions: KGMarkerRegion[];
|
||||||
|
maxBars: number;
|
||||||
|
timeSignature: { numerator: number; denominator: number };
|
||||||
|
selectedRegionIds: string[];
|
||||||
|
editingRegionId: string | null;
|
||||||
|
editingText: string;
|
||||||
|
onEditingTextChange: (value: string) => void;
|
||||||
|
onCommitEdit: (regionId: string) => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onBeginEdit: (regionId: string) => void;
|
||||||
|
onSelectRegion: (regionId: string, options?: RegionClickOptions) => void;
|
||||||
|
onCreateAtBeat: (startBeat: number) => void;
|
||||||
|
onMoveRegion: (regionId: string, startBeat: number) => void;
|
||||||
|
onResizeRegion: (regionId: string, edge: 'start' | 'end', beat: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResizeEdge = 'start' | 'end' | null;
|
||||||
|
|
||||||
|
const REGION_EDGE_HITBOX_PX = 8;
|
||||||
|
const DRAG_THRESHOLD_PX = 4;
|
||||||
|
|
||||||
|
const GlobalMarkerLane: React.FC<GlobalMarkerLaneProps> = ({
|
||||||
|
markerRegions,
|
||||||
|
maxBars,
|
||||||
|
timeSignature,
|
||||||
|
selectedRegionIds,
|
||||||
|
editingRegionId,
|
||||||
|
editingText,
|
||||||
|
onEditingTextChange,
|
||||||
|
onCommitEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onBeginEdit,
|
||||||
|
onSelectRegion,
|
||||||
|
onCreateAtBeat,
|
||||||
|
onMoveRegion,
|
||||||
|
onResizeRegion,
|
||||||
|
}) => {
|
||||||
|
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [previewBeats, setPreviewBeats] = useState<Record<string, { startBeat: number; length: number }>>({});
|
||||||
|
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
|
||||||
|
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||||
|
const interactionRef = useRef<{
|
||||||
|
mode: 'drag' | 'resize' | null;
|
||||||
|
regionId: string;
|
||||||
|
initialMouseX: number;
|
||||||
|
initialStartBeat: number;
|
||||||
|
initialLength: number;
|
||||||
|
resizeEdge: ResizeEdge;
|
||||||
|
moved: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const totalBeats = maxBars * timeSignature.numerator;
|
||||||
|
const beatWidth = useMemo(() => {
|
||||||
|
const barWidth = parseInt(
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||||
|
) || 40;
|
||||||
|
return barWidth / timeSignature.numerator;
|
||||||
|
}, [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));
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
|
||||||
|
const getBeatFromClientX = (clientX: number, mode: 'start' | 'end' = 'start') => {
|
||||||
|
if (!laneRef.current) return 0;
|
||||||
|
const rect = laneRef.current.getBoundingClientRect();
|
||||||
|
const relativeX = clientX - rect.left;
|
||||||
|
const rawBeat = relativeX / beatWidth;
|
||||||
|
return mode === 'end'
|
||||||
|
? clampEndBeat(Math.round(rawBeat))
|
||||||
|
: clampStartBeat(Math.round(rawBeat));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBarSnappedBeatFromClientX = (clientX: number) => {
|
||||||
|
const beat = getBeatFromClientX(clientX);
|
||||||
|
return clampStartBeat(Math.floor(beat / beatsPerBar) * beatsPerBar);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRenderedBeatState = (region: KGMarkerRegion) => (
|
||||||
|
previewBeats[region.getId()] ?? {
|
||||||
|
startBeat: region.getStartFromBeat(),
|
||||||
|
length: region.getLength(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (isModifierKeyPressed(event)) {
|
||||||
|
setIsModifierPressed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 === 'drag') {
|
||||||
|
const beatDelta = Math.round(deltaX / beatWidth);
|
||||||
|
const nextStartBeat = clampStartBeat(interaction.initialStartBeat + beatDelta);
|
||||||
|
setPreviewBeats({
|
||||||
|
[interaction.regionId]: {
|
||||||
|
startBeat: nextStartBeat,
|
||||||
|
length: interaction.initialLength,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.mode === 'resize') {
|
||||||
|
const desiredBeat = getBeatFromClientX(
|
||||||
|
event.clientX,
|
||||||
|
interaction.resizeEdge === 'end' ? 'end' : 'start'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (interaction.resizeEdge === 'start') {
|
||||||
|
const nextStartBeat = Math.min(desiredBeat, interaction.initialStartBeat + interaction.initialLength - 1);
|
||||||
|
const endBeat = interaction.initialStartBeat + interaction.initialLength;
|
||||||
|
setPreviewBeats({
|
||||||
|
[interaction.regionId]: {
|
||||||
|
startBeat: nextStartBeat,
|
||||||
|
length: Math.max(1, endBeat - nextStartBeat),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewBeats({
|
||||||
|
[interaction.regionId]: {
|
||||||
|
startBeat: interaction.initialStartBeat,
|
||||||
|
length: Math.max(1, desiredBeat - interaction.initialStartBeat),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (event: MouseEvent) => {
|
||||||
|
if (!interactionRef.current) return;
|
||||||
|
|
||||||
|
const interaction = interactionRef.current;
|
||||||
|
interactionRef.current = null;
|
||||||
|
|
||||||
|
if (!interaction.moved) {
|
||||||
|
setPreviewBeats({});
|
||||||
|
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = previewBeats[interaction.regionId];
|
||||||
|
setPreviewBeats({});
|
||||||
|
|
||||||
|
if (!preview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.mode === 'drag') {
|
||||||
|
onMoveRegion(interaction.regionId, preview.startBeat);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.mode === 'resize' && interaction.resizeEdge) {
|
||||||
|
const beat = interaction.resizeEdge === 'start'
|
||||||
|
? preview.startBeat
|
||||||
|
: preview.startBeat + preview.length;
|
||||||
|
onResizeRegion(interaction.regionId, interaction.resizeEdge, beat);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [beatWidth, onMoveRegion, onResizeRegion, onSelectRegion, previewBeats, totalBeats]);
|
||||||
|
|
||||||
|
const handleLaneMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
if (!(event.target instanceof HTMLElement)) return;
|
||||||
|
if (event.target.closest('.global-marker-region')) return;
|
||||||
|
if (!isModifierKeyPressed(event)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLaneDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!(event.target instanceof HTMLElement)) return;
|
||||||
|
if (event.target.closest('.global-marker-region')) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={laneRef}
|
||||||
|
className={`global-marker-lane${isModifierPressed ? ' pencil-cursor' : ''}`}
|
||||||
|
onMouseDown={handleLaneMouseDown}
|
||||||
|
onDoubleClick={handleLaneDoubleClick}
|
||||||
|
>
|
||||||
|
{markerRegions.map(region => {
|
||||||
|
const { startBeat, length } = getRenderedBeatState(region);
|
||||||
|
const isSelected = selectedRegionIds.includes(region.getId());
|
||||||
|
const isEditing = editingRegionId === region.getId();
|
||||||
|
const left = startBeat * beatWidth;
|
||||||
|
const width = Math.max(beatWidth, length * beatWidth);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={region.getId()}
|
||||||
|
className={`global-marker-region${isSelected ? ' selected' : ''}`}
|
||||||
|
style={{
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
cursor: isEditing ? 'text' : hoverEdges[region.getId()] ? 'ew-resize' : undefined,
|
||||||
|
}}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onSelectRegion(region.getId(), { shiftKey: false });
|
||||||
|
onBeginEdit(region.getId());
|
||||||
|
}}
|
||||||
|
onMouseMove={(event) => {
|
||||||
|
if (isEditing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interactionRef.current?.regionId === region.getId()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeEdge = getResizeEdgeFromMouseEvent(event);
|
||||||
|
setHoverEdges((current) => (
|
||||||
|
current[region.getId()] === resizeEdge
|
||||||
|
? current
|
||||||
|
: {
|
||||||
|
...current,
|
||||||
|
[region.getId()]: resizeEdge,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoverEdges((current) => {
|
||||||
|
if (!current[region.getId()]) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[region.getId()]: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
if (isEditing) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const resizeEdge = getResizeEdgeFromMouseEvent(event);
|
||||||
|
|
||||||
|
interactionRef.current = {
|
||||||
|
mode: resizeEdge ? 'resize' : 'drag',
|
||||||
|
regionId: region.getId(),
|
||||||
|
initialMouseX: event.clientX,
|
||||||
|
initialStartBeat: region.getStartFromBeat(),
|
||||||
|
initialLength: region.getLength(),
|
||||||
|
resizeEdge,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
className="global-marker-input"
|
||||||
|
value={editingText}
|
||||||
|
onChange={(event) => onEditingTextChange(event.target.value.replace(/\r?\n/g, ' '))}
|
||||||
|
onBlur={() => onCommitEdit(region.getId())}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
onCommitEdit(region.getId());
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancelEdit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="global-marker-label">{region.getName()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalMarkerLane;
|
||||||
+27
-2
@@ -2,6 +2,7 @@ import { Expose, Type } from 'class-transformer';
|
|||||||
import { KGTrack } from './track/KGTrack';
|
import { KGTrack } from './track/KGTrack';
|
||||||
import { KGMidiTrack } from './track/KGMidiTrack';
|
import { KGMidiTrack } from './track/KGMidiTrack';
|
||||||
import { KGAudioTrack } from './track/KGAudioTrack';
|
import { KGAudioTrack } from './track/KGAudioTrack';
|
||||||
|
import { KGChordTrack, KGGlobalTrack, KGMarkerTrack, KGSignatureTrack, KGTempoTrack, createDefaultGlobalTracks } from './global-track';
|
||||||
import { type TimeSignature, WithDefault } from '../types/projectTypes';
|
import { type TimeSignature, WithDefault } from '../types/projectTypes';
|
||||||
import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
import { TIME_CONSTANTS, KEY_SIGNATURE_MAP } from '../constants/coreConstants';
|
||||||
import { RESERVED_PROJECT_NAME } from '../util/projectNameUtil';
|
import { RESERVED_PROJECT_NAME } from '../util/projectNameUtil';
|
||||||
@@ -57,7 +58,7 @@ export class KGProject {
|
|||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 12;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 13;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
@@ -72,8 +73,23 @@ export class KGProject {
|
|||||||
})
|
})
|
||||||
private tracks: KGTrack[] = [];
|
private tracks: KGTrack[] = [];
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@Type(() => KGGlobalTrack, {
|
||||||
|
discriminator: {
|
||||||
|
property: '__type',
|
||||||
|
subTypes: [
|
||||||
|
{ value: KGGlobalTrack, name: 'KGGlobalTrack' },
|
||||||
|
{ value: KGMarkerTrack, name: 'KGMarkerTrack' },
|
||||||
|
{ value: KGTempoTrack, name: 'KGTempoTrack' },
|
||||||
|
{ value: KGSignatureTrack, name: 'KGSignatureTrack' },
|
||||||
|
{ value: KGChordTrack, name: 'KGChordTrack' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
private globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks();
|
||||||
|
|
||||||
// Constructor
|
// Constructor
|
||||||
constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1) {
|
constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 1, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1, globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks()) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.maxBars = maxBars;
|
this.maxBars = maxBars;
|
||||||
this.currentBars = currentBars;
|
this.currentBars = currentBars;
|
||||||
@@ -87,6 +103,7 @@ export class KGProject {
|
|||||||
this.tracks = tracks;
|
this.tracks = tracks;
|
||||||
this.projectStructureVersion = projectStructureVersion;
|
this.projectStructureVersion = projectStructureVersion;
|
||||||
this.pianoRollZoom = pianoRollZoom;
|
this.pianoRollZoom = pianoRollZoom;
|
||||||
|
this.globalTracks = globalTracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
@@ -126,6 +143,10 @@ export class KGProject {
|
|||||||
return this.tracks;
|
return this.tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getGlobalTracks(): KGGlobalTrack[] {
|
||||||
|
return this.globalTracks;
|
||||||
|
}
|
||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
public setName(name: string): void {
|
public setName(name: string): void {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
@@ -155,6 +176,10 @@ export class KGProject {
|
|||||||
this.tracks = tracks;
|
this.tracks = tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setGlobalTracks(globalTracks: KGGlobalTrack[]): void {
|
||||||
|
this.globalTracks = globalTracks;
|
||||||
|
}
|
||||||
|
|
||||||
public setProjectStructureVersion(projectStructureVersion: number): void {
|
public setProjectStructureVersion(projectStructureVersion: number): void {
|
||||||
this.projectStructureVersion = projectStructureVersion;
|
this.projectStructureVersion = projectStructureVersion;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGMarkerRegion } from '../../region/KGMarkerRegion';
|
||||||
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { generateUniqueId } from '../../../util/miscUtil';
|
||||||
|
import {
|
||||||
|
DEFAULT_MARKER_REGION_NAME,
|
||||||
|
findGlobalTrackByType,
|
||||||
|
findMarkerNeighborBounds,
|
||||||
|
getSongEndBeat,
|
||||||
|
} from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
|
export class CreateGlobalMarkerRegionCommand extends KGCommand {
|
||||||
|
private readonly startBeat: number;
|
||||||
|
private readonly preferredLength: number;
|
||||||
|
private readonly regionId: string;
|
||||||
|
private readonly initialName: string;
|
||||||
|
private createdRegion: KGMarkerRegion | null = null;
|
||||||
|
private originalRegionIndex = -1;
|
||||||
|
|
||||||
|
constructor(startBeat: number, preferredLength: number, initialName: string = DEFAULT_MARKER_REGION_NAME, regionId?: string) {
|
||||||
|
super();
|
||||||
|
this.startBeat = startBeat;
|
||||||
|
this.preferredLength = preferredLength;
|
||||||
|
this.initialName = initialName;
|
||||||
|
this.regionId = regionId ?? generateUniqueId('KGMarkerRegion');
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||||
|
if (!markerTrack) {
|
||||||
|
throw new Error('Marker global track not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { maxEndBeat } = findMarkerNeighborBounds(project, null, this.startBeat);
|
||||||
|
const songEndBeat = getSongEndBeat(project);
|
||||||
|
const allowedEndBeat = Math.min(maxEndBeat, songEndBeat);
|
||||||
|
const targetEndBeat = Math.min(this.startBeat + this.preferredLength, allowedEndBeat);
|
||||||
|
const length = Math.max(1, targetEndBeat - this.startBeat);
|
||||||
|
|
||||||
|
this.createdRegion = new KGMarkerRegion(
|
||||||
|
this.regionId,
|
||||||
|
markerTrack.getId(),
|
||||||
|
markerTrack.getTrackIndex(),
|
||||||
|
this.initialName,
|
||||||
|
this.startBeat,
|
||||||
|
length
|
||||||
|
);
|
||||||
|
|
||||||
|
const regions = [...markerTrack.getRegions(), this.createdRegion]
|
||||||
|
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||||
|
this.originalRegionIndex = regions.findIndex(region => region.getId() === this.regionId);
|
||||||
|
markerTrack.setRegions(regions);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.createdRegion) {
|
||||||
|
throw new Error('Cannot undo: no global marker region was created');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||||
|
if (!markerTrack) {
|
||||||
|
throw new Error('Marker global track not found during undo');
|
||||||
|
}
|
||||||
|
|
||||||
|
markerTrack.removeRegion(this.regionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Create marker "${this.initialName}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCreatedRegion(): KGMarkerRegion | null {
|
||||||
|
return this.createdRegion;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
|
||||||
|
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
|
export class DeleteGlobalRegionCommand extends KGCommand {
|
||||||
|
private readonly regionId: string;
|
||||||
|
private deletedRegion: KGGlobalRegion | null = null;
|
||||||
|
private trackId: string | null = null;
|
||||||
|
private originalIndex = -1;
|
||||||
|
|
||||||
|
constructor(regionId: string) {
|
||||||
|
super();
|
||||||
|
this.regionId = regionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const result = findGlobalTrackContainingRegion(project, this.regionId);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`Global region with ID ${this.regionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deletedRegion = result.region;
|
||||||
|
this.trackId = result.track.getId();
|
||||||
|
this.originalIndex = result.regionIndex;
|
||||||
|
result.track.removeRegion(this.regionId);
|
||||||
|
|
||||||
|
const selectedItem = KGCore.instance().getSelectedItems().find(item => item.getId() === this.regionId);
|
||||||
|
if (selectedItem) {
|
||||||
|
KGCore.instance().removeSelectedItem(selectedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.deletedRegion || !this.trackId) {
|
||||||
|
throw new Error('Cannot undo: no deleted global region stored');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const track = project.getGlobalTracks().find(candidate => candidate.getId() === this.trackId);
|
||||||
|
if (!track) {
|
||||||
|
throw new Error(`Global track ${this.trackId} not found during undo`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = [...track.getRegions()];
|
||||||
|
regions.splice(this.originalIndex, 0, this.deletedRegion);
|
||||||
|
track.setRegions(regions);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Delete marker "${this.deletedRegion?.getName() ?? this.regionId}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteMultipleGlobalRegionsCommand extends KGCommand {
|
||||||
|
private readonly regionIds: string[];
|
||||||
|
private deletedRegions: Array<{ region: KGGlobalRegion; trackId: string; originalIndex: number }> = [];
|
||||||
|
|
||||||
|
constructor(regionIds: string[]) {
|
||||||
|
super();
|
||||||
|
this.regionIds = regionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
this.deletedRegions = [];
|
||||||
|
|
||||||
|
for (const regionId of this.regionIds) {
|
||||||
|
const result = findGlobalTrackContainingRegion(project, regionId);
|
||||||
|
if (!result) continue;
|
||||||
|
this.deletedRegions.push({
|
||||||
|
region: result.region,
|
||||||
|
trackId: result.track.getId(),
|
||||||
|
originalIndex: result.regionIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deletedRegions
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.originalIndex - left.originalIndex)
|
||||||
|
.forEach(({ region, trackId }) => {
|
||||||
|
const track = project.getGlobalTracks().find(candidate => candidate.getId() === trackId);
|
||||||
|
track?.removeRegion(region.getId());
|
||||||
|
const selectedItem = KGCore.instance().getSelectedItems().find(item => item.getId() === region.getId());
|
||||||
|
if (selectedItem) {
|
||||||
|
KGCore.instance().removeSelectedItem(selectedItem);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
this.deletedRegions
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => left.originalIndex - right.originalIndex)
|
||||||
|
.forEach(({ region, trackId, originalIndex }) => {
|
||||||
|
const track = project.getGlobalTracks().find(candidate => candidate.getId() === trackId);
|
||||||
|
if (!track) return;
|
||||||
|
const regions = [...track.getRegions()];
|
||||||
|
regions.splice(originalIndex, 0, region);
|
||||||
|
track.setRegions(regions);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return this.regionIds.length === 1 ? 'Delete marker' : `Delete ${this.regionIds.length} markers`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGProject } from '../../KGProject';
|
||||||
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { KGMarkerRegion } from '../../region/KGMarkerRegion';
|
||||||
|
import { CreateGlobalMarkerRegionCommand } from './CreateGlobalMarkerRegionCommand';
|
||||||
|
import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
|
||||||
|
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
|
||||||
|
import { DeleteGlobalRegionCommand } from './DeleteGlobalRegionCommand';
|
||||||
|
import { UpdateGlobalRegionTextCommand } from './UpdateGlobalRegionTextCommand';
|
||||||
|
|
||||||
|
describe('global marker region commands', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const project = new KGProject('Markers', 8, 0, 120);
|
||||||
|
const mockCore = KGCore.instance() as unknown as {
|
||||||
|
getCurrentProject: ReturnType<typeof vi.fn>;
|
||||||
|
getSelectedItems: ReturnType<typeof vi.fn>;
|
||||||
|
removeSelectedItem?: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
mockCore.getCurrentProject.mockReturnValue(project);
|
||||||
|
mockCore.getSelectedItems.mockReturnValue([]);
|
||||||
|
if (!mockCore.removeSelectedItem) {
|
||||||
|
mockCore.removeSelectedItem = vi.fn();
|
||||||
|
} else {
|
||||||
|
mockCore.removeSelectedItem.mockReset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMarkerTrack = () => {
|
||||||
|
const markerTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
|
||||||
|
.find(track => track.getType() === GlobalTrackType.Marker);
|
||||||
|
|
||||||
|
if (!markerTrack) {
|
||||||
|
throw new Error('Marker track missing in test setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
return markerTrack;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a marker region clamped to the next marker start', () => {
|
||||||
|
const markerTrack = getMarkerTrack();
|
||||||
|
markerTrack.addRegion(new KGMarkerRegion('existing', markerTrack.getId(), markerTrack.getTrackIndex(), 'Verse', 10, 4));
|
||||||
|
|
||||||
|
const command = new CreateGlobalMarkerRegionCommand(4, 32, 'Intro');
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
const created = command.getCreatedRegion();
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
expect(created?.getStartFromBeat()).toBe(4);
|
||||||
|
expect(created?.getLength()).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a marker region with beat snapping and neighbor clamping', () => {
|
||||||
|
const markerTrack = getMarkerTrack();
|
||||||
|
const region = new KGMarkerRegion('middle', markerTrack.getId(), markerTrack.getTrackIndex(), 'Middle', 4, 2);
|
||||||
|
markerTrack.setRegions([
|
||||||
|
new KGMarkerRegion('left', markerTrack.getId(), markerTrack.getTrackIndex(), 'Left', 0, 4),
|
||||||
|
region,
|
||||||
|
new KGMarkerRegion('right', markerTrack.getId(), markerTrack.getTrackIndex(), 'Right', 10, 2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const command = new MoveGlobalRegionCommand('middle', 9);
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
expect(region.getStartFromBeat()).toBe(8);
|
||||||
|
|
||||||
|
command.undo();
|
||||||
|
expect(region.getStartFromBeat()).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resizes a marker region with a minimum length of one beat', () => {
|
||||||
|
const markerTrack = getMarkerTrack();
|
||||||
|
const region = new KGMarkerRegion('marker', markerTrack.getId(), markerTrack.getTrackIndex(), 'Marker', 4, 4);
|
||||||
|
markerTrack.setRegions([region]);
|
||||||
|
|
||||||
|
const resizeStartCommand = new ResizeGlobalRegionCommand('marker', 'start', 7);
|
||||||
|
resizeStartCommand.execute();
|
||||||
|
expect(region.getStartFromBeat()).toBe(7);
|
||||||
|
expect(region.getLength()).toBe(1);
|
||||||
|
|
||||||
|
resizeStartCommand.undo();
|
||||||
|
expect(region.getStartFromBeat()).toBe(4);
|
||||||
|
expect(region.getLength()).toBe(4);
|
||||||
|
|
||||||
|
const resizeEndCommand = new ResizeGlobalRegionCommand('marker', 'end', 5);
|
||||||
|
resizeEndCommand.execute();
|
||||||
|
expect(region.getLength()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates text and deletes with undo support', () => {
|
||||||
|
const markerTrack = getMarkerTrack();
|
||||||
|
const region = new KGMarkerRegion('marker', markerTrack.getId(), markerTrack.getTrackIndex(), 'Old', 0, 4);
|
||||||
|
markerTrack.setRegions([region]);
|
||||||
|
|
||||||
|
const renameCommand = new UpdateGlobalRegionTextCommand('marker', 'New');
|
||||||
|
renameCommand.execute();
|
||||||
|
expect(region.getName()).toBe('New');
|
||||||
|
renameCommand.undo();
|
||||||
|
expect(region.getName()).toBe('Old');
|
||||||
|
|
||||||
|
const deleteCommand = new DeleteGlobalRegionCommand('marker');
|
||||||
|
deleteCommand.execute();
|
||||||
|
expect(markerTrack.getRegions()).toHaveLength(0);
|
||||||
|
deleteCommand.undo();
|
||||||
|
expect(markerTrack.getRegions()).toHaveLength(1);
|
||||||
|
expect(markerTrack.getRegions()[0].getId()).toBe('marker');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
|
||||||
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds } from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
|
export class MoveGlobalRegionCommand extends KGCommand {
|
||||||
|
private readonly regionId: string;
|
||||||
|
private readonly desiredStartBeat: number;
|
||||||
|
private targetRegion: KGGlobalRegion | null = null;
|
||||||
|
private originalStartBeat = 0;
|
||||||
|
|
||||||
|
constructor(regionId: string, desiredStartBeat: number) {
|
||||||
|
super();
|
||||||
|
this.regionId = regionId;
|
||||||
|
this.desiredStartBeat = desiredStartBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const result = findGlobalTrackContainingRegion(project, this.regionId);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`Global region with ID ${this.regionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetRegion = result.region;
|
||||||
|
this.originalStartBeat = result.region.getStartFromBeat();
|
||||||
|
|
||||||
|
if (result.track.getType() !== GlobalTrackType.Marker) {
|
||||||
|
result.region.setStartFromBeat(this.desiredStartBeat);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.desiredStartBeat);
|
||||||
|
const maxStartBeat = Math.max(minStartBeat, maxEndBeat - result.region.getLength());
|
||||||
|
const clampedStartBeat = Math.max(minStartBeat, Math.min(this.desiredStartBeat, maxStartBeat));
|
||||||
|
result.region.setStartFromBeat(clampedStartBeat);
|
||||||
|
|
||||||
|
result.track.setRegions([...result.track.getRegions()].sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat()));
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.targetRegion) {
|
||||||
|
throw new Error('Cannot undo: no global region was moved');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetRegion.setStartFromBeat(this.originalStartBeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Move global region "${this.targetRegion?.getName() ?? this.regionId}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { GlobalTrackType } from '../../global-track';
|
||||||
|
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
|
||||||
|
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
|
||||||
|
|
||||||
|
export type GlobalRegionResizeEdge = 'start' | 'end';
|
||||||
|
|
||||||
|
export class ResizeGlobalRegionCommand extends KGCommand {
|
||||||
|
private readonly regionId: string;
|
||||||
|
private readonly edge: GlobalRegionResizeEdge;
|
||||||
|
private readonly desiredBeat: number;
|
||||||
|
private targetRegion: KGGlobalRegion | null = null;
|
||||||
|
private originalStartBeat = 0;
|
||||||
|
private originalLength = 0;
|
||||||
|
|
||||||
|
constructor(regionId: string, edge: GlobalRegionResizeEdge, desiredBeat: number) {
|
||||||
|
super();
|
||||||
|
this.regionId = regionId;
|
||||||
|
this.edge = edge;
|
||||||
|
this.desiredBeat = desiredBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const result = findGlobalTrackContainingRegion(project, this.regionId);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`Global region with ID ${this.regionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetRegion = result.region;
|
||||||
|
this.originalStartBeat = result.region.getStartFromBeat();
|
||||||
|
this.originalLength = result.region.getLength();
|
||||||
|
|
||||||
|
if (result.track.getType() !== GlobalTrackType.Marker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalEndBeat = this.originalStartBeat + this.originalLength;
|
||||||
|
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.originalStartBeat);
|
||||||
|
const songEndBeat = getSongEndBeat(project);
|
||||||
|
const absoluteMaxEndBeat = Math.min(maxEndBeat, songEndBeat);
|
||||||
|
|
||||||
|
if (this.edge === 'start') {
|
||||||
|
const clampedStartBeat = Math.max(minStartBeat, Math.min(this.desiredBeat, originalEndBeat - 1));
|
||||||
|
result.region.setStartFromBeat(clampedStartBeat);
|
||||||
|
result.region.setLength(Math.max(1, originalEndBeat - clampedStartBeat));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedEndBeat = Math.max(this.originalStartBeat + 1, Math.min(this.desiredBeat, absoluteMaxEndBeat));
|
||||||
|
result.region.setLength(Math.max(1, clampedEndBeat - this.originalStartBeat));
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.targetRegion) {
|
||||||
|
throw new Error('Cannot undo: no global region was resized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetRegion.setStartFromBeat(this.originalStartBeat);
|
||||||
|
this.targetRegion.setLength(this.originalLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Resize global region "${this.targetRegion?.getName() ?? this.regionId}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
|
||||||
|
|
||||||
|
export class UpdateGlobalRegionTextCommand extends KGCommand {
|
||||||
|
private readonly regionId: string;
|
||||||
|
private readonly nextText: string;
|
||||||
|
private previousText = '';
|
||||||
|
|
||||||
|
constructor(regionId: string, nextText: string) {
|
||||||
|
super();
|
||||||
|
this.regionId = regionId;
|
||||||
|
this.nextText = nextText;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`Global region with ID ${this.regionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previousText = result.region.getName();
|
||||||
|
result.region.setName(this.nextText);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`Global region with ID ${this.regionId} not found during undo`);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.region.setName(this.previousText);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return `Rename marker to "${this.nextText}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,13 @@ export type { StemImportEntry } from './region/ImportStemsCommand';
|
|||||||
export { SplitRegionCommand } from './region/SplitRegionCommand';
|
export { SplitRegionCommand } from './region/SplitRegionCommand';
|
||||||
export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
|
export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
|
||||||
|
|
||||||
|
// Global region commands
|
||||||
|
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
|
||||||
|
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
|
||||||
|
export { ResizeGlobalRegionCommand, type GlobalRegionResizeEdge } from './global-region/ResizeGlobalRegionCommand';
|
||||||
|
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
|
||||||
|
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
|
||||||
|
|
||||||
// Note commands
|
// Note commands
|
||||||
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
export { CreateNoteCommand } from './note/CreateNoteCommand';
|
||||||
export { DeleteMidiEventsCommand } from './note/DeleteMidiEventsCommand';
|
export { DeleteMidiEventsCommand } from './note/DeleteMidiEventsCommand';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Expose } from 'class-transformer';
|
||||||
|
import { GlobalTrackType, KGGlobalTrack } from './KGGlobalTrack';
|
||||||
|
|
||||||
|
export class KGChordTrack extends KGGlobalTrack {
|
||||||
|
@Expose()
|
||||||
|
protected override __type: string = 'KGChordTrack';
|
||||||
|
|
||||||
|
constructor(id: string = 'global-chord', trackIndex: number = 3, name: string = 'Chord') {
|
||||||
|
super(id, trackIndex, GlobalTrackType.Chord, name, []);
|
||||||
|
this.__type = 'KGChordTrack';
|
||||||
|
}
|
||||||
|
|
||||||
|
public override getCurrentType(): string {
|
||||||
|
return 'KGChordTrack';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Expose, Type } from 'class-transformer';
|
||||||
|
import { KGGlobalRegion } from '../region/KGGlobalRegion';
|
||||||
|
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
||||||
|
|
||||||
|
export enum GlobalTrackType {
|
||||||
|
Marker = 'marker',
|
||||||
|
Tempo = 'tempo',
|
||||||
|
Signature = 'signature',
|
||||||
|
Chord = 'chord',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KGGlobalTrack {
|
||||||
|
@Expose()
|
||||||
|
protected __type: string = 'KGGlobalTrack';
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
protected id: string = '';
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
protected trackIndex: number = 0;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
protected type: GlobalTrackType = GlobalTrackType.Marker;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
protected name: string = '';
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@Type(() => KGGlobalRegion, {
|
||||||
|
discriminator: {
|
||||||
|
property: '__type',
|
||||||
|
subTypes: [
|
||||||
|
{ value: KGGlobalRegion, name: 'KGGlobalRegion' },
|
||||||
|
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
protected regions: KGGlobalRegion[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string = '',
|
||||||
|
trackIndex: number = 0,
|
||||||
|
type: GlobalTrackType = GlobalTrackType.Marker,
|
||||||
|
name: string = '',
|
||||||
|
regions: KGGlobalRegion[] = []
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.trackIndex = trackIndex;
|
||||||
|
this.type = type;
|
||||||
|
this.name = name;
|
||||||
|
this.regions = regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getId(): string {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setId(id: string): void {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTrackIndex(): number {
|
||||||
|
return this.trackIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTrackIndex(trackIndex: number): void {
|
||||||
|
this.trackIndex = trackIndex;
|
||||||
|
this.regions.forEach(region => {
|
||||||
|
region.setTrackIndex(trackIndex);
|
||||||
|
region.setTrackId(this.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getType(): GlobalTrackType {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setType(type: GlobalTrackType): void {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): string {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setName(name: string): void {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRegions(): KGGlobalRegion[] {
|
||||||
|
return this.regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setRegions(regions: KGGlobalRegion[]): void {
|
||||||
|
this.regions = regions.map(region => {
|
||||||
|
region.setTrackId(this.id);
|
||||||
|
region.setTrackIndex(this.trackIndex);
|
||||||
|
return region;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public addRegion(region: KGGlobalRegion): void {
|
||||||
|
region.setTrackId(this.id);
|
||||||
|
region.setTrackIndex(this.trackIndex);
|
||||||
|
this.regions.push(region);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeRegion(regionId: string): void {
|
||||||
|
this.regions = this.regions.filter(region => region.getId() !== regionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCurrentType(): string {
|
||||||
|
return 'KGGlobalTrack';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Expose } from 'class-transformer';
|
||||||
|
import { GlobalTrackType, KGGlobalTrack } from './KGGlobalTrack';
|
||||||
|
|
||||||
|
export class KGMarkerTrack extends KGGlobalTrack {
|
||||||
|
@Expose()
|
||||||
|
protected override __type: string = 'KGMarkerTrack';
|
||||||
|
|
||||||
|
constructor(id: string = 'global-marker', trackIndex: number = 0, name: string = 'Marker') {
|
||||||
|
super(id, trackIndex, GlobalTrackType.Marker, name, []);
|
||||||
|
this.__type = 'KGMarkerTrack';
|
||||||
|
}
|
||||||
|
|
||||||
|
public override getCurrentType(): string {
|
||||||
|
return 'KGMarkerTrack';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Expose } from 'class-transformer';
|
||||||
|
import { GlobalTrackType, KGGlobalTrack } from './KGGlobalTrack';
|
||||||
|
|
||||||
|
export class KGSignatureTrack extends KGGlobalTrack {
|
||||||
|
@Expose()
|
||||||
|
protected override __type: string = 'KGSignatureTrack';
|
||||||
|
|
||||||
|
constructor(id: string = 'global-signature', trackIndex: number = 2, name: string = 'Signature') {
|
||||||
|
super(id, trackIndex, GlobalTrackType.Signature, name, []);
|
||||||
|
this.__type = 'KGSignatureTrack';
|
||||||
|
}
|
||||||
|
|
||||||
|
public override getCurrentType(): string {
|
||||||
|
return 'KGSignatureTrack';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Expose } from 'class-transformer';
|
||||||
|
import { GlobalTrackType, KGGlobalTrack } from './KGGlobalTrack';
|
||||||
|
|
||||||
|
export class KGTempoTrack extends KGGlobalTrack {
|
||||||
|
@Expose()
|
||||||
|
protected override __type: string = 'KGTempoTrack';
|
||||||
|
|
||||||
|
constructor(id: string = 'global-tempo', trackIndex: number = 1, name: string = 'Tempo') {
|
||||||
|
super(id, trackIndex, GlobalTrackType.Tempo, name, []);
|
||||||
|
this.__type = 'KGTempoTrack';
|
||||||
|
}
|
||||||
|
|
||||||
|
public override getCurrentType(): string {
|
||||||
|
return 'KGTempoTrack';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { KGChordTrack } from './KGChordTrack';
|
||||||
|
import { KGGlobalTrack } from './KGGlobalTrack';
|
||||||
|
import { KGMarkerTrack } from './KGMarkerTrack';
|
||||||
|
import { KGSignatureTrack } from './KGSignatureTrack';
|
||||||
|
import { KGTempoTrack } from './KGTempoTrack';
|
||||||
|
|
||||||
|
export function createDefaultGlobalTracks(): KGGlobalTrack[] {
|
||||||
|
return [
|
||||||
|
new KGMarkerTrack(),
|
||||||
|
new KGTempoTrack(),
|
||||||
|
new KGSignatureTrack(),
|
||||||
|
new KGChordTrack(),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { KGGlobalTrack, GlobalTrackType } from './KGGlobalTrack';
|
||||||
|
export { KGMarkerTrack } from './KGMarkerTrack';
|
||||||
|
export { KGTempoTrack } from './KGTempoTrack';
|
||||||
|
export { KGSignatureTrack } from './KGSignatureTrack';
|
||||||
|
export { KGChordTrack } from './KGChordTrack';
|
||||||
|
export { createDefaultGlobalTracks } from './createDefaultGlobalTracks';
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
|
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
|
||||||
import { KGProject } from '../KGProject';
|
import { KGProject } from '../KGProject';
|
||||||
|
import { GlobalTrackType } from '../global-track';
|
||||||
|
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
||||||
import { KGTrack } from '../track/KGTrack';
|
import { KGTrack } from '../track/KGTrack';
|
||||||
|
|
||||||
// --- OPFS mock infrastructure ---
|
// --- OPFS mock infrastructure ---
|
||||||
@@ -159,6 +161,24 @@ describe('KGProjectStorage', () => {
|
|||||||
expect(loaded!.getPianoRollZoom()).toBe(5);
|
expect(loaded!.getPianoRollZoom()).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves global tracks and marker regions when saving and loading', async () => {
|
||||||
|
const project = createTestProject('Marker Song');
|
||||||
|
const markerTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Marker);
|
||||||
|
|
||||||
|
expect(markerTrack).toBeDefined();
|
||||||
|
markerTrack?.addRegion(new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 8));
|
||||||
|
|
||||||
|
await storage.save('Marker Song', project);
|
||||||
|
|
||||||
|
const loaded = await storage.load('Marker Song');
|
||||||
|
const loadedMarkerTrack = loaded?.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Marker);
|
||||||
|
|
||||||
|
expect(loadedMarkerTrack).toBeDefined();
|
||||||
|
expect(loadedMarkerTrack?.getRegions()).toHaveLength(1);
|
||||||
|
expect(loadedMarkerTrack?.getRegions()[0]).toBeInstanceOf(KGMarkerRegion);
|
||||||
|
expect(loadedMarkerTrack?.getRegions()[0].getName()).toBe('Intro');
|
||||||
|
});
|
||||||
|
|
||||||
it('creates meta.json and media/ directory on save', async () => {
|
it('creates meta.json and media/ directory on save', async () => {
|
||||||
const project = createTestProject('My Song');
|
const project = createTestProject('My Song');
|
||||||
await storage.save('My Song', project);
|
await storage.save('My Song', project);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { upgradeToV9 } from './upgradeToV9';
|
|||||||
import { upgradeToV10 } from './upgradeToV10';
|
import { upgradeToV10 } from './upgradeToV10';
|
||||||
import { upgradeToV11 } from './upgradeToV11';
|
import { upgradeToV11 } from './upgradeToV11';
|
||||||
import { upgradeToV12 } from './upgradeToV12';
|
import { upgradeToV12 } from './upgradeToV12';
|
||||||
|
import { upgradeToV13 } from './upgradeToV13';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upgrade the given project to the latest structure version, one version at a time.
|
* Upgrade the given project to the latest structure version, one version at a time.
|
||||||
@@ -78,6 +79,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
|||||||
workingProject = upgradeToV12(workingProject);
|
workingProject = upgradeToV12(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 13: {
|
||||||
|
workingProject = upgradeToV13(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { KGProject } from '../KGProject';
|
||||||
|
import { GlobalTrackType } from '../global-track';
|
||||||
|
import { upgradeProjectToLatest } from './KGProjectUpgrader';
|
||||||
|
import { upgradeToV13 } from './upgradeToV13';
|
||||||
|
|
||||||
|
describe('upgradeToV13', () => {
|
||||||
|
it('adds the default global tracks to legacy projects', () => {
|
||||||
|
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 12, 1, []);
|
||||||
|
|
||||||
|
upgradeToV13(project);
|
||||||
|
|
||||||
|
expect(project.getProjectStructureVersion()).toBe(13);
|
||||||
|
expect(project.getGlobalTracks()).toHaveLength(4);
|
||||||
|
expect(project.getGlobalTracks().map(track => track.getType())).toEqual([
|
||||||
|
GlobalTrackType.Marker,
|
||||||
|
GlobalTrackType.Tempo,
|
||||||
|
GlobalTrackType.Signature,
|
||||||
|
GlobalTrackType.Chord,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs through the main upgrader path', () => {
|
||||||
|
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 12, 1, []);
|
||||||
|
|
||||||
|
const upgraded = upgradeProjectToLatest(project);
|
||||||
|
|
||||||
|
expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
|
||||||
|
expect(upgraded.getGlobalTracks()).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { KGProject } from '../KGProject';
|
||||||
|
import { ensureDefaultGlobalTracks } from '../../util/globalTrackUtil';
|
||||||
|
|
||||||
|
export function upgradeToV13(project: KGProject): KGProject {
|
||||||
|
try {
|
||||||
|
ensureDefaultGlobalTracks(project);
|
||||||
|
} finally {
|
||||||
|
project.setProjectStructureVersion(13);
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Expose } from 'class-transformer';
|
||||||
|
import { KGRegion } from './KGRegion';
|
||||||
|
|
||||||
|
export class KGGlobalRegion extends KGRegion {
|
||||||
|
@Expose()
|
||||||
|
protected override __type: string = 'KGGlobalRegion';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
trackId: string,
|
||||||
|
trackIndex: number,
|
||||||
|
name: string,
|
||||||
|
startFromBeat: number = 0,
|
||||||
|
length: number = 0
|
||||||
|
) {
|
||||||
|
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||||
|
this.__type = 'KGGlobalRegion';
|
||||||
|
}
|
||||||
|
|
||||||
|
public override getRootType(): string {
|
||||||
|
return 'KGRegion';
|
||||||
|
}
|
||||||
|
|
||||||
|
public override getCurrentType(): string {
|
||||||
|
return 'KGGlobalRegion';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Expose } from 'class-transformer';
|
||||||
|
import { KGGlobalRegion } from './KGGlobalRegion';
|
||||||
|
|
||||||
|
export class KGMarkerRegion extends KGGlobalRegion {
|
||||||
|
@Expose()
|
||||||
|
protected override __type: string = 'KGMarkerRegion';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
trackId: string,
|
||||||
|
trackIndex: number,
|
||||||
|
name: string,
|
||||||
|
startFromBeat: number = 0,
|
||||||
|
length: number = 0
|
||||||
|
) {
|
||||||
|
super(id, trackId, trackIndex, name, startFromBeat, length);
|
||||||
|
this.__type = 'KGMarkerRegion';
|
||||||
|
}
|
||||||
|
|
||||||
|
public override getCurrentType(): string {
|
||||||
|
return 'KGMarkerRegion';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
import { KGTrack } from '../core/track/KGTrack';
|
import { KGTrack } from '../core/track/KGTrack';
|
||||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||||
|
import { KGGlobalTrack } from '../core/global-track';
|
||||||
import type { TimeSignature } from '../types/projectTypes';
|
import type { TimeSignature } from '../types/projectTypes';
|
||||||
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
|
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
|
||||||
import { beatsToTimeString } from '../util/timeUtil';
|
import { beatsToTimeString } from '../util/timeUtil';
|
||||||
@@ -69,6 +70,7 @@ interface ProjectState {
|
|||||||
projectName: string;
|
projectName: string;
|
||||||
savedProjectName: string; // OPFS folder name where the project is currently saved
|
savedProjectName: string; // OPFS folder name where the project is currently saved
|
||||||
tracks: KGTrack[];
|
tracks: KGTrack[];
|
||||||
|
globalTracks: KGGlobalTrack[];
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
maxBars: number;
|
maxBars: number;
|
||||||
barWidthMultiplier: number;
|
barWidthMultiplier: number;
|
||||||
@@ -405,6 +407,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
projectName: currentProject.getName(),
|
projectName: currentProject.getName(),
|
||||||
savedProjectName: currentProject.getName(),
|
savedProjectName: currentProject.getName(),
|
||||||
tracks: currentProject.getTracks() as KGTrack[],
|
tracks: currentProject.getTracks() as KGTrack[],
|
||||||
|
globalTracks: currentProject.getGlobalTracks() as KGGlobalTrack[],
|
||||||
currentStatus: KGCore.instance().getStatus() || 'Unknown',
|
currentStatus: KGCore.instance().getStatus() || 'Unknown',
|
||||||
maxBars: currentProject.getMaxBars(),
|
maxBars: currentProject.getMaxBars(),
|
||||||
barWidthMultiplier: currentProject.getBarWidthMultiplier(),
|
barWidthMultiplier: currentProject.getBarWidthMultiplier(),
|
||||||
@@ -517,7 +520,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Update the store state with a new array reference to trigger re-render
|
// Update the store state with a new array reference to trigger re-render
|
||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
set({ tracks: [...project.getTracks()] as KGTrack[] });
|
set({
|
||||||
|
tracks: [...project.getTracks()] as KGTrack[],
|
||||||
|
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-select the newly created track and open instrument selection panel
|
// Auto-select the newly created track and open instrument selection panel
|
||||||
const newTrackId = command.getTrackId().toString();
|
const newTrackId = command.getTrackId().toString();
|
||||||
@@ -539,7 +545,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
KGCore.instance().executeCommand(command);
|
KGCore.instance().executeCommand(command);
|
||||||
|
|
||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
set({ tracks: [...project.getTracks()] as KGTrack[] });
|
set({
|
||||||
|
tracks: [...project.getTracks()] as KGTrack[],
|
||||||
|
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
||||||
|
});
|
||||||
|
|
||||||
const newTrackId = command.getTrackId().toString();
|
const newTrackId = command.getTrackId().toString();
|
||||||
set({
|
set({
|
||||||
@@ -659,7 +668,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Update the store state with a new array reference to trigger re-render
|
// Update the store state with a new array reference to trigger re-render
|
||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
const remainingTracks = [...project.getTracks()] as KGTrack[];
|
const remainingTracks = [...project.getTracks()] as KGTrack[];
|
||||||
set({ tracks: remainingTracks });
|
set({
|
||||||
|
tracks: remainingTracks,
|
||||||
|
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-select another track if any remain
|
// Auto-select another track if any remain
|
||||||
if (remainingTracks.length > 0) {
|
if (remainingTracks.length > 0) {
|
||||||
@@ -744,7 +756,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Update the store state with the current project state
|
// Update the store state with the current project state
|
||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
set({ tracks: [...project.getTracks()] as KGTrack[] });
|
set({
|
||||||
|
tracks: [...project.getTracks()] as KGTrack[],
|
||||||
|
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`Updated track ${trackId} properties`);
|
console.log(`Updated track ${trackId} properties`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -773,7 +788,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Update the store state with the current project state
|
// Update the store state with the current project state
|
||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
set({ tracks: [...project.getTracks()] as KGTrack[] });
|
set({
|
||||||
|
tracks: [...project.getTracks()] as KGTrack[],
|
||||||
|
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`Reordered track from index ${sourceIndex} to ${destinationIndex}`);
|
console.log(`Reordered track from index ${sourceIndex} to ${destinationIndex}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -902,6 +920,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
projectName: projectToLoad.getName(),
|
projectName: projectToLoad.getName(),
|
||||||
savedProjectName: savedName ?? projectToLoad.getName(),
|
savedProjectName: savedName ?? projectToLoad.getName(),
|
||||||
tracks: [...tracks],
|
tracks: [...tracks],
|
||||||
|
globalTracks: [...projectToLoad.getGlobalTracks()] as KGGlobalTrack[],
|
||||||
maxBars,
|
maxBars,
|
||||||
barWidthMultiplier: projectToLoad.getBarWidthMultiplier(),
|
barWidthMultiplier: projectToLoad.getBarWidthMultiplier(),
|
||||||
timeSignature,
|
timeSignature,
|
||||||
@@ -1822,6 +1841,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
set({
|
set({
|
||||||
projectName: project.getName(),
|
projectName: project.getName(),
|
||||||
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
|
tracks: [...project.getTracks()] as KGTrack[], // Force new array reference - key for re-rendering!
|
||||||
|
globalTracks: [...project.getGlobalTracks()] as KGGlobalTrack[],
|
||||||
maxBars: project.getMaxBars(),
|
maxBars: project.getMaxBars(),
|
||||||
barWidthMultiplier: project.getBarWidthMultiplier(),
|
barWidthMultiplier: project.getBarWidthMultiplier(),
|
||||||
timeSignature: project.getTimeSignature(),
|
timeSignature: project.getTimeSignature(),
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { KGProject } from '../core/KGProject';
|
||||||
|
import {
|
||||||
|
GlobalTrackType,
|
||||||
|
KGGlobalTrack,
|
||||||
|
createDefaultGlobalTracks,
|
||||||
|
} from '../core/global-track';
|
||||||
|
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||||
|
|
||||||
|
export const DEFAULT_MARKER_REGION_NAME = 'Marker';
|
||||||
|
|
||||||
|
export function ensureDefaultGlobalTracks(project: KGProject): KGGlobalTrack[] {
|
||||||
|
const existingTracks = project.getGlobalTracks?.() ?? [];
|
||||||
|
const nextTracks = createDefaultGlobalTracks();
|
||||||
|
|
||||||
|
for (const existingTrack of existingTracks) {
|
||||||
|
const matchedTrack = nextTracks.find(track => track.getType() === existingTrack.getType());
|
||||||
|
if (matchedTrack) {
|
||||||
|
matchedTrack.setRegions(existingTrack.getRegions());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTracks.forEach((track, index) => {
|
||||||
|
track.setTrackIndex(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
project.setGlobalTracks(nextTracks);
|
||||||
|
return nextTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSongEndBeat(project: KGProject): number {
|
||||||
|
return project.getMaxBars() * project.getTimeSignature().numerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findGlobalTrackByType(project: KGProject, type: GlobalTrackType): KGGlobalTrack | null {
|
||||||
|
return project.getGlobalTracks().find(track => track.getType() === type) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findGlobalTrackContainingRegion(
|
||||||
|
project: KGProject,
|
||||||
|
regionId: string
|
||||||
|
): { track: KGGlobalTrack; region: KGGlobalRegion; regionIndex: number } | null {
|
||||||
|
for (const track of project.getGlobalTracks()) {
|
||||||
|
const regionIndex = track.getRegions().findIndex(region => region.getId() === regionId);
|
||||||
|
if (regionIndex !== -1) {
|
||||||
|
const region = track.getRegions()[regionIndex];
|
||||||
|
return { track, region, regionIndex };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMarkerNeighborBounds(
|
||||||
|
project: KGProject,
|
||||||
|
regionId: string | null,
|
||||||
|
proposedStartBeat: number
|
||||||
|
): { minStartBeat: number; maxEndBeat: number; nextStartBeat: number | null } {
|
||||||
|
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||||
|
const songEndBeat = getSongEndBeat(project);
|
||||||
|
|
||||||
|
if (!markerTrack) {
|
||||||
|
return { minStartBeat: 0, maxEndBeat: songEndBeat, nextStartBeat: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherRegions = markerTrack.getRegions()
|
||||||
|
.filter(region => region.getId() !== regionId)
|
||||||
|
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||||
|
|
||||||
|
let minStartBeat = 0;
|
||||||
|
let maxEndBeat = songEndBeat;
|
||||||
|
let nextStartBeat: number | null = null;
|
||||||
|
|
||||||
|
for (const region of otherRegions) {
|
||||||
|
if (region.getStartFromBeat() < proposedStartBeat) {
|
||||||
|
minStartBeat = Math.max(minStartBeat, region.getStartFromBeat() + region.getLength());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
maxEndBeat = Math.min(maxEndBeat, region.getStartFromBeat());
|
||||||
|
nextStartBeat = region.getStartFromBeat();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { minStartBeat, maxEndBeat, nextStartBeat };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user