import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react'; import './MainContent.css'; import { createPortal } from 'react-dom'; import { useProjectStore } from '../stores/projectStore'; import { KGCore } from '../core/KGCore'; import { KGTrack } from '../core/track/KGTrack'; import { KGRegion } from '../core/region/KGRegion'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; import { KGAudioRegion } from '../core/region/KGAudioRegion'; import TrackInfoPanel from './track/TrackInfoPanel'; import TrackGridPanel from './track/TrackGridPanel'; import PianoRoll from './piano-roll/PianoRoll'; import type { RegionClickOptions, RegionUI } from './interfaces'; import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS, TOOLBAR_CONSTANTS } from '../constants'; import { useRegionOperations } from '../hooks/useRegionOperations'; import { regionDeleteManager } from '../util/regionDeleteUtil'; import { KGMainContentState } from '../core/state/KGMainContentState'; import { ChangeLoopSettingsCommand } from '../core/commands'; interface MainContentProps { onTrackClick?: () => void; } const MainContent: React.FC = ({ onTrackClick = () => { } // Default to empty function if not provided }) => { const { tracks, maxBars, barWidthMultiplier, reorderTracks, updateTrack, updateTrackProperties, timeSignature, setPlayheadPosition, playheadPosition, isPlaying, autoScrollEnabled, setAutoScrollEnabled, clearAllSelections, setSelectedTrack, selectedRegionIds, showPianoRoll, activeRegionId, setShowPianoRoll, setActiveRegionId, pianoRollMode, openMidiPianoRoll, openSpectrogramViewer, openHybridMode, hybridAudioRegionId, addTrack, addAudioTrack, projectName, savedProjectName, requestPianoRollScroll, mainContentScrollRequest, } = useProjectStore(); // State to store regions const [regions, setRegions] = useState([]); // Drag state for track grid highlighting const [draggedTrackIndex, setDraggedTrackIndex] = useState(null); const [dragOverTrackIndex, setDragOverTrackIndex] = useState(null); // Piano roll state is now managed by the store - removed local state // Region selection state const [selectedRegionId, setSelectedRegionId] = useState(null); // Use the region operations hook const { deleteSelectedRegions } = useRegionOperations({ tracks, updateTrack, setRegions, selectedRegionId, setSelectedRegionId, showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId }); // Register the delete function with the global manager useEffect(() => { regionDeleteManager.registerDeleteCallback(deleteSelectedRegions); // Cleanup on unmount return () => { regionDeleteManager.unregisterDeleteCallback(); }; }, [deleteSelectedRegions]); // Refs to track pending updates for verification const pendingUpdates = useRef>(new Map()); const preventEmptyMainContentDeselectRef = useRef(false); // Refs for auto-scroll during playback const mainContentRef = useRef(null); const expectedScrollLeftRef = useRef(-1); const isPlayingRef = useRef(false); const previousBarWidthMultiplierRef = useRef(barWidthMultiplier); // Refs for bar numbers and loop range drag functionality const barNumbersRef = useRef(null); const isLoopDraggingRef = useRef(false); const loopDragStartBarRef = useRef(null); const loopDragStartXRef = useRef(null); const loopDragOriginalSettingsRef = useRef<{ isLooping: boolean; loopingRange: [number, number] } | null>(null); // Sync isPlayingRef for use inside scroll event closure useEffect(() => { isPlayingRef.current = isPlaying; }, [isPlaying]); useLayoutEffect(() => { const previousMultiplier = previousBarWidthMultiplierRef.current; if (previousMultiplier === barWidthMultiplier) return; previousBarWidthMultiplierRef.current = barWidthMultiplier; const container = mainContentRef.current; if (!container) return; const infoWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width') ) || 200; const visibleMusicWidth = Math.max(0, container.clientWidth - infoWidth); const previousBarWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * previousMultiplier; const nextBarWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier; if (visibleMusicWidth === 0 || previousBarWidth === 0 || nextBarWidth === 0) return; const centerPixelBeforeZoom = container.scrollLeft + visibleMusicWidth / 2; const anchorBeat = (centerPixelBeforeZoom / previousBarWidth) * timeSignature.numerator; const targetPixel = (anchorBeat / timeSignature.numerator) * nextBarWidth; const targetScrollLeft = targetPixel - visibleMusicWidth / 2; const clampedScrollLeft = Math.max( 0, Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth) ); expectedScrollLeftRef.current = clampedScrollLeft; container.scrollLeft = clampedScrollLeft; }, [barWidthMultiplier, timeSignature]); // Detect manual horizontal scroll during playback useEffect(() => { const container = mainContentRef.current; if (!container) return; const handleScroll = () => { if (!isPlayingRef.current) return; if (Math.abs(container.scrollLeft - expectedScrollLeftRef.current) < 1) return; useProjectStore.getState().setAutoScrollEnabled(false); }; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); }, []); // Auto-scroll to keep playhead centered during playback useEffect(() => { if (!isPlaying || !autoScrollEnabled) return; const container = mainContentRef.current; if (!container) return; const beatsPerBar = timeSignature.numerator; const barPosition = playheadPosition / beatsPerBar; const barWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width') ) || 40; const playheadPixel = barPosition * barWidth; // Center the playhead in the visible grid area (excluding the sticky info panel) const infoWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width') ) || 200; const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2; const clampedScrollLeft = Math.max( 0, Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth) ); expectedScrollLeftRef.current = clampedScrollLeft; container.scrollLeft = clampedScrollLeft; }, [playheadPosition, isPlaying, autoScrollEnabled, timeSignature]); // Handle scroll requests from piano roll header clicks useEffect(() => { if (mainContentScrollRequest === null) return; const container = mainContentRef.current; if (!container) return; const beatsPerBar = timeSignature.numerator; const barPosition = mainContentScrollRequest / beatsPerBar; const barWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width') ) || 40; const playheadPixel = barPosition * barWidth; // Center the playhead in the visible grid area (excluding the sticky info panel) const infoWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width') ) || 200; const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2; const clampedScrollLeft = Math.max( 0, Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth) ); container.scrollLeft = clampedScrollLeft; // Clear the request after handling useProjectStore.setState({ mainContentScrollRequest: null }); }, [mainContentScrollRequest, timeSignature]); // Effect to verify track updates useEffect(() => { // Check for pending updates if (pendingUpdates.current.size > 0) { // Create a copy of the pending updates const updates = new Map(pendingUpdates.current); // Clear pending updates pendingUpdates.current.clear(); // Check each update updates.forEach((update, key) => { const { trackId, regionId, startBeat, length } = update; // Find the track const track = tracks.find(t => t.getId().toString() === trackId); if (track) { // Find the region const regions = track.getRegions(); const region = regions.find(r => r.getId() === regionId); if (region && DEBUG_MODE.MAIN_CONTENT) { console.log(`Verification - Region ${regionId} in track ${trackId}:`); console.log(` Expected: startBeat=${startBeat}, length=${length}`); console.log(` Actual: startBeat=${region.getStartFromBeat()}, length=${region.getLength()}, trackId=${region.getTrackId()}, trackIndex=${region.getTrackIndex()}`); // Check if the update was successful const success = region.getStartFromBeat() === startBeat && region.getLength() === length && region.getTrackId() === trackId; console.log(` Update successful: ${success}`); } } }); } }, [tracks]); // Effect to update regions when tracks change useEffect(() => { // Create a new array of RegionUI objects based on the current tracks const updatedRegions: RegionUI[] = []; // Iterate through all tracks tracks.forEach(track => { const trackId = track.getId().toString(); const trackIndex = track.getTrackIndex(); // Iterate through all regions in the track track.getRegions().forEach(region => { if (region instanceof KGMidiRegion || region instanceof KGAudioRegion) { // Calculate bar number and length from beats const beatsPerBar = timeSignature.numerator; const barNumber = (region.getStartFromBeat() / beatsPerBar) + 1; const length = region.getLength() / beatsPerBar; // Create a RegionUI object updatedRegions.push({ id: region.getId(), trackId, trackIndex, barNumber, length, name: region.getName() }); } }); }); // Update the regions state setRegions(updatedRegions); }, [tracks, timeSignature]); // Handle track name edit const handleTrackNameEdit = (track: KGTrack, newName: string) => { // Use the command pattern to update track name with undo support updateTrackProperties(track.getId(), { name: newName }); }; // Handle track reordering const handleTracksReordered = (fromIndex: number, toIndex: number) => { // Reorder tracks in the store - this will also update trackIndex in each KGTrack reorderTracks(fromIndex, toIndex); // Update regions to match the new track order setRegions(prevRegions => { return prevRegions.map(region => { // If the region belongs to the dragged track, update its trackIndex if (region.trackIndex === fromIndex) { return { ...region, trackIndex: toIndex }; } // If the region belongs to a track that was shifted due to the drag operation else if ( (fromIndex < toIndex && region.trackIndex > fromIndex && region.trackIndex <= toIndex) ) { // Shift up by 1 return { ...region, trackIndex: region.trackIndex - 1 }; } else if ( (fromIndex > toIndex && region.trackIndex < fromIndex && region.trackIndex >= toIndex) ) { // Shift down by 1 return { ...region, trackIndex: region.trackIndex + 1 }; } // Otherwise leave it unchanged return region; }); }); // Update the grid drag state to match setDraggedTrackIndex(null); setDragOverTrackIndex(null); }; // Handle region creation from TrackGridPanel const handleRegionCreated = (trackIndex: number, regionUI: RegionUI, midiRegion: KGMidiRegion) => { // Note: The region model is already created by the CreateRegionCommand // We just need to update the UI state and handle selection // Get the track for store updates const track = tracks[trackIndex]; // Update the track in the store to reflect the command changes updateTrack(track); // Select the track that contains the new region setSelectedTrack(track.getId().toString()); // Add the new region to the UI state and select it immediately setRegions(prevRegions => { const updatedRegions = [...prevRegions, regionUI]; // Select the region using the updated regions array selectRegion(regionUI.id, { shiftKey: false }, updatedRegions); // Manually trigger selection sync to ensure UI updates immediately const { syncSelectionFromCore } = useProjectStore.getState(); syncSelectionFromCore(); // If piano roll is visible, set this region as the active region if (showPianoRoll) { setActiveRegionId(regionUI.id); if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Newly created region ${regionUI.id} set as active region in piano roll`); } } return updatedRegions; }); }; // Handle regions dropped from K.G.One panel (external drag-and-drop) const handleExternalDropComplete = (trackIndex: number, regionUI: RegionUI) => { const track = tracks[trackIndex]; if (!track) return; updateTrack(track); setSelectedTrack(track.getId().toString()); // Sync maxBars from core model — ImportAudioCommand may have expanded it const coreMaxBars = KGCore.instance().getCurrentProject().getMaxBars(); if (coreMaxBars > maxBars) { useProjectStore.setState({ maxBars: coreMaxBars }); document.documentElement.style.setProperty('--max-number-of-bars', coreMaxBars.toString()); } setRegions(prev => { const updated = [...prev, regionUI]; selectRegion(regionUI.id, { shiftKey: false }, updated); return updated; }); }; // Handle region updates (resize, move, etc.) const handleRegionUpdated = ( regionId: string, updates: Partial, expectedModelUpdates?: { startBeat: number, length: number } ) => { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Updating region ${regionId} with:`, updates); } // Select the region when it's being updated (resize or move) selectRegion(regionId); // Find the region to determine which track to select const updatedRegion = regions.find(r => r.id === regionId); if (updatedRegion) { // Use the updated trackId if available, otherwise use the existing trackId const trackId = updates.trackId || updatedRegion.trackId; const track = tracks.find(t => t.getId().toString() === trackId); if (track) { setSelectedTrack(track.getId().toString()); } } // Update the region in the UI state setRegions(prevRegions => { return prevRegions.map(region => { if (region.id === regionId) { return { ...region, ...updates }; } return region; }); }); // Find the region that was updated const region = regions.find(r => r.id === regionId); if (!region) return; // Check if the track ID is being updated (region moved to different track) if (updates.trackId && updates.trackId !== region.trackId) { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Region ${regionId} moved from track ${region.trackId} to track ${updates.trackId}`); } // Get the original track const originalTrack = tracks.find(t => t.getId().toString() === region.trackId); // Get the target track const targetTrack = tracks.find(t => t.getId().toString() === updates.trackId); if (originalTrack && targetTrack) { // Select the target track that now contains the region setSelectedTrack(targetTrack.getId().toString()); // Update both tracks in the store updateTrack(originalTrack); updateTrack(targetTrack); // Add to pending updates for verification if (expectedModelUpdates) { const key = `${updates.trackId}-${regionId}-${Date.now()}`; pendingUpdates.current.set(key, { trackId: updates.trackId, regionId, startBeat: expectedModelUpdates.startBeat, length: expectedModelUpdates.length }); } } } else { // Find the track that contains this region const track = tracks.find(t => t.getId().toString() === region.trackId); if (track) { // Log the track's regions before updating the store const trackRegions = track.getRegions(); const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined; if (midiRegion) { // If we have expected model updates, use those if (expectedModelUpdates) { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`MainContent - Expected model updates: startBeat=${expectedModelUpdates.startBeat}, length=${expectedModelUpdates.length}`); } // Add to pending updates for verification const key = `${track.getId()}-${regionId}-${Date.now()}`; pendingUpdates.current.set(key, { trackId: track.getId().toString(), regionId, startBeat: expectedModelUpdates.startBeat, length: expectedModelUpdates.length }); } else { // Otherwise use the current values (for backward compatibility) const startBeat = midiRegion.getStartFromBeat(); const length = midiRegion.getLength(); if (DEBUG_MODE.MAIN_CONTENT) { console.log(`MainContent - Region before store update: startBeat=${startBeat}, length=${length}`); } // Add to pending updates for verification const key = `${track.getId()}-${regionId}-${Date.now()}`; pendingUpdates.current.set(key, { trackId: track.getId().toString(), regionId, startBeat, length }); } } // Update the track in the store to persist changes updateTrack(track); } } // Keep the active piano roll region in sync when that same region is updated. // Do not switch the editor to an unrelated region from generic move/resize updates. if (showPianoRoll && activeRegionId === regionId) { setActiveRegionId(regionId); if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Updated region ${regionId} set as active region in piano roll`); } } }; // Helper function to select a region (clears previous selections) const applyRegionSelection = (orderedSelectionIds: string[]) => { const core = KGCore.instance(); tracks.forEach(projectTrack => { projectTrack.getRegions().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; }) .filter((selectedRegion): selectedRegion is KGRegion => selectedRegion !== null); if (selectedRegions.length > 0) { core.addSelectedItems(selectedRegions); } const lastSelectedRegionId = selectedRegions.length > 0 ? selectedRegions[selectedRegions.length - 1].getId() : null; setSelectedRegionId(lastSelectedRegionId); if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Selected regions: ${selectedRegions.map(selectedRegion => selectedRegion.getId()).join(', ')}`); } if (!showPianoRoll) { return; } if (!lastSelectedRegionId) { setShowPianoRoll(false); setActiveRegionId(null); return; } const lastSelectedRegion = selectedRegions[selectedRegions.length - 1]; if (lastSelectedRegion instanceof KGAudioRegion) { openSpectrogramViewer(lastSelectedRegionId); } else if (lastSelectedRegion instanceof KGMidiRegion) { openMidiPianoRoll(lastSelectedRegionId); } }; const selectRegion = ( regionId: string, options: RegionClickOptions = { shiftKey: false }, regionsToSearch?: RegionUI[] ) => { const regionsToUse = regionsToSearch || regions; const region = regionsToUse.find(r => r.id === regionId); if (!region) { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Region not found in UI state: ${regionId}`); } return; } const track = tracks.find(t => t.getId().toString() === region.trackId); if (!track) { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Track not found for region: ${regionId}`); } return; } const coreRegion = track.getRegions().find(r => r.getId() === regionId); if (!coreRegion) { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Region not found in track model: ${regionId}`); } return; } const orderedSelection = options.shiftKey ? (selectedRegionIds.includes(regionId) ? selectedRegionIds.filter(id => id !== regionId) : [...selectedRegionIds, regionId]) : [regionId]; applyRegionSelection(orderedSelection); }; const handleRegionLassoSelection = (regionIds: string[], options: RegionClickOptions = { shiftKey: false }) => { const orderedRegionIds = regionIds.filter(regionId => regions.some(region => region.id === regionId)); const orderedSelection = options.shiftKey ? orderedRegionIds.reduce((nextSelection, regionId) => { if (nextSelection.includes(regionId)) { return nextSelection.filter(id => id !== regionId); } return [...nextSelection, regionId]; }, [...selectedRegionIds]) : orderedRegionIds; applyRegionSelection(orderedSelection); }; const handleEmptyMainContentClick = (e: React.MouseEvent) => { if (e.target !== e.currentTarget) { return; } if (preventEmptyMainContentDeselectRef.current) { preventEmptyMainContentDeselectRef.current = false; return; } handleRegionLassoSelection([], { shiftKey: false }); }; const handleRegionLassoCommit = () => { preventEmptyMainContentDeselectRef.current = true; }; // Handle region single click: selection only (no piano roll opening) const handleRegionClick = (regionId: string, options: RegionClickOptions = { shiftKey: false }) => { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Region clicked in MainContent (selection only): ${regionId}`); } // Select the region selectRegion(regionId, options); // Also select the containing track const region = regions.find(r => r.id === regionId); if (!region) return; const track = tracks.find(t => t.getId().toString() === region.trackId); if (!track) return; setSelectedTrack(track.getId().toString()); }; // Handle explicit pencil action: select region and open piano roll const handleOpenPianoRoll = (regionId: string) => { // Don't open piano roll for audio regions const project = KGCore.instance().getCurrentProject(); for (const track of project.getTracks()) { const region = track.getRegions().find(r => r.getId() === regionId); if (region && region.getCurrentType() === 'KGAudioRegion') { return; } } if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Open piano roll via pencil for region: ${regionId}`); } // Reuse selection logic handleRegionClick(regionId, { shiftKey: false }); // Activate and show piano roll in midi-edit mode openMidiPianoRoll(regionId); }; // Handle spectrogram viewer open const handleOpenSpectrogram = (regionId: string) => { handleRegionClick(regionId, { shiftKey: false }); openSpectrogramViewer(regionId); }; // Handle hybrid mode open (+ button clicked on opposite-type region) const handleOpenHybrid = (regionId: string) => { if (pianoRollMode === 'midi-edit' && activeRegionId) { openHybridMode(activeRegionId, regionId); } else if (pianoRollMode === 'spectrogram' && activeRegionId) { openHybridMode(regionId, activeRegionId); } }; // + button is visible only when piano roll is open and mode is not hybrid const showHybridButtonForAudio = showPianoRoll && pianoRollMode === 'midi-edit'; const showHybridButtonForMidi = showPianoRoll && pianoRollMode === 'spectrogram'; // Handle piano roll close const handlePianoRollClose = () => { setShowPianoRoll(false); setActiveRegionId(null); }; /** * Add keyboard event listener for region deletion * Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions * Only processes deletion when not in the piano roll (piano roll has its own delete handler) */ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Skip if user is typing in an input field (including ChatBox) const target = event.target as HTMLElement; if (target && ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true' || target.hasAttribute('data-chatbox-input') || target.closest('.chatbox-input') )) { return; } // Handle delete key for selected regions (Backspace on Windows, Delete on Mac) if (event.key === 'Backspace' || event.key === 'Delete') { // 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; if (!isInPianoRoll && !isPianoRollOpen) { const deleted = deleteSelectedRegions(); if (deleted) { // Prevent default behavior only if regions were actually deleted event.preventDefault(); } } } }; // Add event listener window.addEventListener('keydown', handleKeyDown); // Remove event listener on cleanup return () => { window.removeEventListener('keydown', handleKeyDown); }; }, [deleteSelectedRegions, showPianoRoll]); // Dependencies for the effect // Utility function to calculate playhead position from mouse coordinates (bar-level snapping) const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => { if (!barNumbersRef.current) return null; const rect = barNumbersRef.current.getBoundingClientRect(); const relativeX = clientX - rect.left; // Calculate the width of each bar const barWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width') ) || 40; // Find the closest bar start; honor snapping toggle const snap = KGMainContentState.instance().isSnappingEnabled(); const barIndex = snap ? Math.round(relativeX / barWidth) : relativeX / barWidth; // Ensure we don't go below 0 const clampedBarIndex = Math.max(0, barIndex); // Calculate destination beat position (start of the bar) const beatsPerBar = timeSignature.numerator; const destinationBeatPosition = clampedBarIndex * beatsPerBar; return destinationBeatPosition; }, [timeSignature]); // Utility function to calculate bar index from mouse coordinates (for loop range selection) const calculateBarIndexFromMouse = useCallback((clientX: number): number | null => { if (!barNumbersRef.current) return null; const rect = barNumbersRef.current.getBoundingClientRect(); const relativeX = clientX - rect.left; // Calculate the width of each bar const barWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width') ) || 40; // Calculate bar index (using Math.floor for exact bar boundaries) const barIndex = Math.floor(relativeX / barWidth); // Clamp to valid range [0, maxBars - 1] return Math.max(0, Math.min(barIndex, maxBars - 1)); }, [maxBars]); // Handle mouse down to start dragging (for loop range selection) const handleBarNumbersMouseDown = (e: React.MouseEvent) => { // Only handle left mouse button if (e.button !== 0) return; // Calculate starting bar index const startBarIndex = calculateBarIndexFromMouse(e.clientX); if (startBarIndex === null) return; // Always start loop drag tracking isLoopDraggingRef.current = true; loopDragStartBarRef.current = startBarIndex; loopDragStartXRef.current = e.clientX; // Capture original loop settings for undo/redo loopDragOriginalSettingsRef.current = { isLooping, loopingRange: [...loopingRange] as [number, number] }; if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Bar numbers mouse down - Start bar: ${startBarIndex} (displayed as bar ${startBarIndex + 1})`); } // Prevent text selection during drag e.preventDefault(); }; // Global mouse move and mouse up handlers for loop range drag functionality useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isLoopDraggingRef.current) return; if (loopDragStartBarRef.current === null || loopDragStartXRef.current === null) return; // Calculate distance moved const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current); // Only update if moved beyond threshold if (distanceMoved < BAR_NUMBERS_CONSTANTS.DRAG_THRESHOLD) return; // Calculate current bar index const currentBarIndex = calculateBarIndexFromMouse(e.clientX); if (currentBarIndex === null) return; // Create loop range [min, max] regardless of drag direction const startBar = loopDragStartBarRef.current; const loopStart = Math.min(startBar, currentBarIndex); const loopEnd = Math.max(startBar, currentBarIndex); const newLoopRange: [number, number] = [loopStart, loopEnd]; // Update project model const core = KGCore.instance(); const project = core.getCurrentProject(); project.setLoopingRange(newLoopRange); project.setIsLooping(true); // Enable looping immediately during drag for real-time visual feedback // Update store to trigger UI re-render useProjectStore.setState({ loopingRange: newLoopRange, isLooping: true }); if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Loop range drag - Range: [${loopStart}, ${loopEnd}] (bars ${loopStart + 1}-${loopEnd + 1})`); } }; const handleMouseUp = (e: MouseEvent) => { if (isLoopDraggingRef.current) { if (loopDragStartXRef.current !== null) { const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current); // If dragged beyond threshold, execute command for undo/redo support if (distanceMoved >= BAR_NUMBERS_CONSTANTS.DRAG_THRESHOLD) { const core = KGCore.instance(); const currentIsLooping = core.getCurrentProject().getIsLooping(); const currentLoopingRange = core.getCurrentProject().getLoopingRange(); // Only execute command if settings actually changed from original if (loopDragOriginalSettingsRef.current) { const originalSettings = loopDragOriginalSettingsRef.current; const settingsChanged = originalSettings.isLooping !== currentIsLooping || originalSettings.loopingRange[0] !== currentLoopingRange[0] || originalSettings.loopingRange[1] !== currentLoopingRange[1]; if (settingsChanged) { // Stop playback if currently playing (get fresh state from store) const { isPlaying: currentIsPlaying, stopPlaying: currentStopPlaying } = useProjectStore.getState(); if (currentIsPlaying) { currentStopPlaying(); } // Revert to original state first (since we updated in real-time) core.getCurrentProject().setIsLooping(originalSettings.isLooping); core.getCurrentProject().setLoopingRange(originalSettings.loopingRange); // Now execute command to apply new settings with undo support const command = new ChangeLoopSettingsCommand({ isLooping: currentIsLooping, loopingRange: currentLoopingRange }); core.executeCommand(command); if (DEBUG_MODE.MAIN_CONTENT) { console.log('Loop range drag ended - Command executed for undo/redo'); } } } } else { // Single click (moved < threshold) - set playhead position const clickPosition = calculatePlayheadFromMouse(e.clientX); if (clickPosition !== null) { setPlayheadPosition(clickPosition); requestPianoRollScroll(clickPosition); if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Single click on bar numbers - Set playhead to: ${clickPosition}`); } } } } // Reset drag state isLoopDraggingRef.current = false; loopDragStartBarRef.current = null; loopDragStartXRef.current = null; loopDragOriginalSettingsRef.current = null; } }; // Add global event listeners for drag functionality document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); // Cleanup event listeners on unmount return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [calculateBarIndexFromMouse, calculatePlayheadFromMouse, setPlayheadPosition, requestPianoRollScroll]); const { showInstrumentSelection, isLooping, loopingRange } = useProjectStore(); // Helper function to check if a bar (0-indexed) is in the loop range const isBarInLoopRange = (barIndex: number): boolean => { if (!isLooping) return false; // Loop range is [startBar, endBar] (0-indexed) // We want to highlight bars from startBar to endBar inclusive return barIndex >= loopingRange[0] && barIndex <= loopingRange[1]; }; return (
{/* Top-left spacer */}
{/* Bar numbers at the top */}
{Array.from({ length: maxBars }, (_, i) => (
{i + 1}
))}
{/* Fixed left panel with track info */} {/* Scrollable grid area */}
{/* Piano Roll / Spectrogram Viewer - render using portal */} {showPianoRoll && createPortal( { // spectrogram mode: audio region IS the activeRegionId // hybrid mode: audio region is hybridAudioRegionId const audioId = pianoRollMode === 'spectrogram' ? activeRegionId : pianoRollMode === 'hybrid' ? hybridAudioRegionId : null; if (!audioId) return undefined; for (const track of tracks) { const region = track.getRegions().find(r => r.getId() === audioId); if (region && region.getCurrentType() === 'KGAudioRegion') { return region as unknown as KGAudioRegion; } } return undefined; })()} trackId={(() => { const audioId = pianoRollMode === 'spectrogram' ? activeRegionId : pianoRollMode === 'hybrid' ? hybridAudioRegionId : null; if (!audioId) return undefined; for (const track of tracks) { if (track.getRegions().some(r => r.getId() === audioId)) { return track.getId().toString(); } } return undefined; })()} projectName={savedProjectName} />, document.body )}
); }; export default MainContent;