From fb3c6cde70aa10cbfd3d4f05f51f74aa2fc2a1a2 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 23 May 2026 12:56:43 -0700 Subject: [PATCH] feat: implemented global marker track --- src/components/MainContent.css | 75 ++++ src/components/MainContent.test.tsx | 7 +- src/components/MainContent.tsx | 252 +++++++++++-- .../global-track/GlobalMarkerLane.tsx | 348 ++++++++++++++++++ src/core/KGProject.ts | 29 +- .../CreateGlobalMarkerRegionCommand.ts | 78 ++++ .../DeleteGlobalRegionCommand.ts | 109 ++++++ .../GlobalMarkerCommands.test.ts | 109 ++++++ .../global-region/MoveGlobalRegionCommand.ts | 53 +++ .../ResizeGlobalRegionCommand.ts | 67 ++++ .../UpdateGlobalRegionTextCommand.ts | 38 ++ src/core/commands/index.ts | 7 + src/core/global-track/KGChordTrack.ts | 16 + src/core/global-track/KGGlobalTrack.ts | 115 ++++++ src/core/global-track/KGMarkerTrack.ts | 16 + src/core/global-track/KGSignatureTrack.ts | 16 + src/core/global-track/KGTempoTrack.ts | 16 + .../global-track/createDefaultGlobalTracks.ts | 14 + src/core/global-track/index.ts | 6 + src/core/io/KGProjectStorage.test.ts | 20 + .../project-upgrader/KGProjectUpgrader.ts | 5 + .../project-upgrader/upgradeToV13.test.ts | 31 ++ src/core/project-upgrader/upgradeToV13.ts | 12 + src/core/region/KGGlobalRegion.ts | 27 ++ src/core/region/KGMarkerRegion.ts | 23 ++ src/stores/projectStore.ts | 30 +- src/util/globalTrackUtil.ts | 85 +++++ 27 files changed, 1566 insertions(+), 38 deletions(-) create mode 100644 src/components/global-track/GlobalMarkerLane.tsx create mode 100644 src/core/commands/global-region/CreateGlobalMarkerRegionCommand.ts create mode 100644 src/core/commands/global-region/DeleteGlobalRegionCommand.ts create mode 100644 src/core/commands/global-region/GlobalMarkerCommands.test.ts create mode 100644 src/core/commands/global-region/MoveGlobalRegionCommand.ts create mode 100644 src/core/commands/global-region/ResizeGlobalRegionCommand.ts create mode 100644 src/core/commands/global-region/UpdateGlobalRegionTextCommand.ts create mode 100644 src/core/global-track/KGChordTrack.ts create mode 100644 src/core/global-track/KGGlobalTrack.ts create mode 100644 src/core/global-track/KGMarkerTrack.ts create mode 100644 src/core/global-track/KGSignatureTrack.ts create mode 100644 src/core/global-track/KGTempoTrack.ts create mode 100644 src/core/global-track/createDefaultGlobalTracks.ts create mode 100644 src/core/global-track/index.ts create mode 100644 src/core/project-upgrader/upgradeToV13.test.ts create mode 100644 src/core/project-upgrader/upgradeToV13.ts create mode 100644 src/core/region/KGGlobalRegion.ts create mode 100644 src/core/region/KGMarkerRegion.ts create mode 100644 src/util/globalTrackUtil.ts diff --git a/src/components/MainContent.css b/src/components/MainContent.css index 19b92f5..df06203 100644 --- a/src/components/MainContent.css +++ b/src/components/MainContent.css @@ -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 { position: sticky; left: 0; diff --git a/src/components/MainContent.test.tsx b/src/components/MainContent.test.tsx index a48abea..5da74cf 100644 --- a/src/components/MainContent.test.tsx +++ b/src/components/MainContent.test.tsx @@ -5,6 +5,7 @@ import MainContent from './MainContent'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import { KGAudioTrack } from '../core/track/KGAudioTrack'; +import { createDefaultGlobalTracks } from '../core/global-track'; import { createMockMidiTrack } from '../test/utils/mock-data'; const midiRegion = new KGMidiRegion('region-1', '1', 0, 'Region 1', 0, 4); @@ -17,6 +18,7 @@ audioTrack.setRegions([audioRegion]); const storeState = { tracks: [midiTrack, audioTrack], + globalTracks: createDefaultGlobalTracks(), maxBars: 8, barWidthMultiplier: 1, reorderTracks: vi.fn(), @@ -328,19 +330,18 @@ describe('MainContent', () => { 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(); fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' })); 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 Signature global track item' }), screen.getByRole('button', { name: 'Add Chord global track item' }), ]; - expect(addButtons).toHaveLength(4); + expect(addButtons).toHaveLength(3); addButtons.forEach(button => { fireEvent.click(button); diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 32fdf09..ef64af6 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -3,12 +3,16 @@ import './MainContent.css'; import { createPortal } from 'react-dom'; import { useProjectStore } from '../stores/projectStore'; import { KGCore } from '../core/KGCore'; +import { GlobalTrackType } from '../core/global-track'; import { KGTrack } from '../core/track/KGTrack'; import { KGRegion } from '../core/region/KGRegion'; +import { KGGlobalRegion } from '../core/region/KGGlobalRegion'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; +import { KGMarkerRegion } from '../core/region/KGMarkerRegion'; import TrackInfoPanel from './track/TrackInfoPanel'; import TrackGridPanel from './track/TrackGridPanel'; +import GlobalMarkerLane from './global-track/GlobalMarkerLane'; import PianoRoll from './piano-roll/PianoRoll'; import { TrackCreateDialog } from './common'; 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 { regionDeleteManager } from '../util/regionDeleteUtil'; import { KGMainContentState } from '../core/state/KGMainContentState'; -import { ChangeLoopSettingsCommand } from '../core/commands'; -import { DeleteTrackAutomationPointsCommand } from '../core/commands'; +import { + 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 { FaSquareArrowUpRight } from 'react-icons/fa6'; @@ -42,6 +54,7 @@ const MainContent: React.FC = ({ }) => { const { tracks, + globalTracks, maxBars, barWidthMultiplier, reorderTracks, @@ -95,6 +108,8 @@ const MainContent: React.FC = ({ const [showGlobalTracksMock, setShowGlobalTracksMock] = useState(false); const [renderGlobalTracksMock, setRenderGlobalTracksMock] = useState(false); const [animateGlobalTracksMock, setAnimateGlobalTracksMock] = useState(false); + const [editingGlobalRegionId, setEditingGlobalRegionId] = useState(null); + const [editingGlobalRegionText, setEditingGlobalRegionText] = useState(''); // Use the region operations hook const { deleteSelectedRegions } = useRegionOperations({ @@ -143,9 +158,60 @@ const MainContent: React.FC = ({ 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 useEffect(() => { regionDeleteManager.registerDeleteCallback(() => { + if (deleteSelectedGlobalRegions()) { + return true; + } if (deleteSelectedTrackAutomationPoints()) { return true; } @@ -156,7 +222,7 @@ const MainContent: React.FC = ({ return () => { regionDeleteManager.unregisterDeleteCallback(); }; - }, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints]); + }, [deleteSelectedGlobalRegions, deleteSelectedRegions, deleteSelectedTrackAutomationPoints]); // Refs to track pending updates for verification const pendingUpdates = useRef>(new Map()); @@ -594,26 +660,21 @@ const MainContent: React.FC = ({ // Helper function to select a region (clears previous selections) const applyRegionSelection = (orderedSelectionIds: string[]) => { const core = KGCore.instance(); + const allRegions = [ + ...tracks.flatMap(projectTrack => projectTrack.getRegions()), + ...globalTracks.flatMap(globalTrack => globalTrack.getRegions()), + ]; - tracks.forEach(projectTrack => { - projectTrack.getRegions().forEach(projectRegion => projectRegion.deselect()); - }); + allRegions.forEach(projectRegion => projectRegion.deselect()); clearAllSelections(); const selectedRegions: KGRegion[] = orderedSelectionIds - .map(selectedId => { - for (const projectTrack of tracks) { - const selectedRegion = projectTrack.getRegions().find(r => r.getId() === selectedId); - if (selectedRegion) { - selectedRegion.select(); - return selectedRegion as KGRegion; - } - } - return null; - }) + .map(selectedId => allRegions.find(region => region.getId() === selectedId) ?? null) .filter((selectedRegion): selectedRegion is KGRegion => selectedRegion !== null); + selectedRegions.forEach(selectedRegion => selectedRegion.select()); + if (selectedRegions.length > 0) { core.addSelectedItems(selectedRegions); } @@ -621,9 +682,12 @@ const MainContent: React.FC = ({ const lastSelectedRegionId = selectedRegions.length > 0 ? selectedRegions[selectedRegions.length - 1].getId() : null; + const lastSelectedRegion = selectedRegions.length > 0 + ? selectedRegions[selectedRegions.length - 1] + : null; setSelectedRegionId(lastSelectedRegionId); - if (lastSelectedRegionId) { + if (lastSelectedRegionId && lastSelectedRegion && !(lastSelectedRegion instanceof KGGlobalRegion)) { setActiveRegionId(lastSelectedRegionId); } @@ -639,7 +703,10 @@ const MainContent: React.FC = ({ return; } - const lastSelectedRegion = selectedRegions[selectedRegions.length - 1]; + if (!lastSelectedRegion || lastSelectedRegion instanceof KGGlobalRegion) { + return; + } + if (lastSelectedRegion instanceof KGAudioRegion) { openSpectrogramViewer(lastSelectedRegionId); } else if (lastSelectedRegion instanceof KGMidiRegion) { @@ -677,10 +744,22 @@ const MainContent: React.FC = ({ return; } + const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId)); const orderedSelection = options.shiftKey ? (selectedRegionIds.includes(regionId) - ? selectedRegionIds.filter(id => id !== regionId) - : [...selectedRegionIds, regionId]) + ? regularSelectedRegionIds.filter(id => id !== 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]; applyRegionSelection(orderedSelection); @@ -688,13 +767,14 @@ const MainContent: React.FC = ({ const handleRegionLassoSelection = (regionIds: string[], options: RegionClickOptions = { shiftKey: false }) => { const orderedRegionIds = regionIds.filter(regionId => regions.some(region => region.id === regionId)); + const regularSelectedRegionIds = selectedRegionIds.filter(selectedId => !isGlobalRegionId(selectedId)); const orderedSelection = options.shiftKey ? orderedRegionIds.reduce((nextSelection, regionId) => { if (nextSelection.includes(regionId)) { return nextSelection.filter(id => id !== regionId); } return [...nextSelection, regionId]; - }, [...selectedRegionIds]) + }, [...regularSelectedRegionIds]) : orderedRegionIds; applyRegionSelection(orderedSelection); @@ -787,6 +867,95 @@ const MainContent: React.FC = ({ 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 * Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions @@ -811,9 +980,10 @@ const MainContent: React.FC = ({ // 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 isPianoRollOpen = showPianoRoll; + const hasSelectedGlobalRegions = selectedRegionIds.some(regionId => isGlobalRegionId(regionId)); - if (!isInPianoRoll && !isPianoRollOpen) { - const deleted = deleteSelectedTrackAutomationPoints() || deleteSelectedRegions(); + if (!isInPianoRoll && (!isPianoRollOpen || hasSelectedGlobalRegions)) { + const deleted = deleteSelectedGlobalRegions() || deleteSelectedTrackAutomationPoints() || deleteSelectedRegions(); if (deleted) { // Prevent default behavior only if regions were actually deleted event.preventDefault(); @@ -829,7 +999,7 @@ const MainContent: React.FC = ({ return () => { 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) const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => { @@ -1134,6 +1304,9 @@ const MainContent: React.FC = ({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); + if (track.id === 'marker') { + createMarkerAtPlayheadBar(); + } }} > @@ -1145,10 +1318,33 @@ const MainContent: React.FC = ({