diff --git a/src/App.css b/src/App.css index 6b00ca2..5bbb13e 100644 --- a/src/App.css +++ b/src/App.css @@ -268,6 +268,11 @@ body { box-sizing: border-box; } +.bar-number-cell.looped { + background-color: #e1ae01; + color: #1e1e1e; +} + .main-content-body { display: flex; min-height: fit-content; diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 90cceec..c35d5a4 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -8,7 +8,7 @@ import TrackInfoPanel from './track/TrackInfoPanel'; import TrackGridPanel from './track/TrackGridPanel'; import PianoRoll from './piano-roll/PianoRoll'; import type { RegionUI } from './interfaces'; -import { DEBUG_MODE } from '../constants'; +import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } from '../constants'; import { useRegionOperations } from '../hooks/useRegionOperations'; import { regionDeleteManager } from '../util/regionDeleteUtil'; @@ -73,9 +73,11 @@ const MainContent: React.FC = ({ // Refs to track pending updates for verification const pendingUpdates = useRef>(new Map()); - // Refs for bar numbers drag functionality - const isDraggingRef = useRef(false); + // 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); // Effect to verify track updates useEffect(() => { @@ -515,75 +517,115 @@ const MainContent: React.FC = ({ return destinationBeatPosition; }, [timeSignature]); - // Handle mouse down to start dragging + // 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; - - isDraggingRef.current = true; - - // Calculate and set initial playhead position - const newPosition = calculatePlayheadFromMouse(e.clientX); - if (newPosition !== null) { - setPlayheadPosition(newPosition); - - if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`Bar numbers drag started - Initial position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`); - } + + // 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; + + 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(); }; - // Handle click on bar numbers to move playhead (when not dragging) - const handleBarNumbersClick = (e: React.MouseEvent) => { - // If we were dragging, don't process as a click - if (isDraggingRef.current) { - return; - } - - const newPosition = calculatePlayheadFromMouse(e.clientX); - if (newPosition !== null) { - const core = KGCore.instance(); - const currentPlayheadPosition = core.getPlayheadPosition(); - const beatsPerBar = timeSignature.numerator; - const currentBarNumber = Math.floor(currentPlayheadPosition / beatsPerBar) + 1; // 1-indexed - const destinationBarNumber = Math.floor(newPosition / beatsPerBar) + 1; // 1-indexed - - // Debug logging - if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`Bar numbers click - Position: ${newPosition}`); - console.log(`Current bar: ${currentBarNumber} (beat ${currentPlayheadPosition})`); - console.log(`Destination bar: ${destinationBarNumber} (beat ${newPosition})`); - } - - setPlayheadPosition(newPosition); - } - }; - - // Global mouse move and mouse up handlers for bar numbers drag functionality + // Global mouse move and mouse up handlers for loop range drag functionality useEffect(() => { const handleMouseMove = (e: MouseEvent) => { - if (!isDraggingRef.current) return; - - const newPosition = calculatePlayheadFromMouse(e.clientX); - if (newPosition !== null) { - setPlayheadPosition(newPosition); - - if (DEBUG_MODE.MAIN_CONTENT) { - console.log(`Bar numbers drag - Position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`); - } + 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 = () => { - if (isDraggingRef.current) { - isDraggingRef.current = false; - - if (DEBUG_MODE.MAIN_CONTENT) { - console.log('Bar numbers drag ended'); + const handleMouseUp = (e: MouseEvent) => { + if (isLoopDraggingRef.current) { + if (loopDragStartXRef.current !== null) { + const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current); + + // If dragged beyond threshold, enable looping + if (distanceMoved >= BAR_NUMBERS_CONSTANTS.DRAG_THRESHOLD) { + const core = KGCore.instance(); + const project = core.getCurrentProject(); + project.setIsLooping(true); + useProjectStore.setState({ isLooping: true }); + + if (DEBUG_MODE.MAIN_CONTENT) { + console.log('Loop range drag ended - Looping auto-enabled'); + } + } else { + // Single click (moved < threshold) - set playhead position + const clickPosition = calculatePlayheadFromMouse(e.clientX); + if (clickPosition !== null) { + setPlayheadPosition(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; } }; @@ -596,25 +638,37 @@ const MainContent: React.FC = ({ document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - }, [calculatePlayheadFromMouse, setPlayheadPosition, timeSignature]); + }, [calculateBarIndexFromMouse, calculatePlayheadFromMouse, setPlayheadPosition]); - const { showInstrumentSelection } = useProjectStore(); + 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}
+
+ {i + 1} +
))}
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index b21e984..2b09402 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -7,9 +7,9 @@ import { useProjectStore } from '../stores/projectStore'; import { DEBUG_MODE } from '../constants/uiConstants'; import { TIME_CONSTANTS } from '../constants/coreConstants'; import { parseTimeSignature, getTimeSignatureErrorMessage } from '../util/timeUtil'; -import { - FaUndo, FaRedo, FaMousePointer, FaStepBackward, - FaPlay, FaPause, FaComments, +import { + FaUndo, FaRedo, FaMousePointer, FaStepBackward, + FaPlay, FaPause, FaComments, FaSync, FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus, FaCog } from 'react-icons/fa'; @@ -27,12 +27,13 @@ import { clearChatHistoryAndUI } from '../util/chatUtil'; import PianoIcon from './common/icons/PianoIcon'; const Toolbar: React.FC = () => { - const { - projectName, setProjectName, + const { + projectName, setProjectName, bpm, timeSignature, keySignature, setStatus, isPlaying, startPlaying, stopPlaying, setPlayheadPosition, currentTime, setBpm, setTimeSignature, setKeySignature, maxBars, setMaxBars, + isLooping, loopingRange, canUndo, canRedo, undoDescription, redoDescription, undo, redo, toggleChatBox, toggleSettings, cleanupProjectState, // Piano roll state/actions @@ -43,7 +44,7 @@ const Toolbar: React.FC = () => { // State for main content tools const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer'); - + // State for key signature dropdown const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false); @@ -412,6 +413,50 @@ const Toolbar: React.FC = () => { setPlayheadPosition(0); }; + const handleLoopToggle = () => { + const core = KGCore.instance(); + const project = core.getCurrentProject(); + const newLoopingState = !isLooping; + let newLoopingRange = loopingRange; + + // When enabling loop, validate and set the loop range + if (newLoopingState) { + const currentRange = loopingRange; + const projectMaxBars = maxBars; + + // If range is [0, 0], set it to the entire song + if (currentRange[0] === 0 && currentRange[1] === 0) { + newLoopingRange = [0, projectMaxBars] as [number, number]; + if (DEBUG_MODE.TOOLBAR) { + console.log("Loop range auto-set to entire song:", newLoopingRange); + } + } else { + // Validate range is within [0, maxBars] + const validatedStart = Math.max(0, Math.min(currentRange[0], projectMaxBars)); + const validatedEnd = Math.max(0, Math.min(currentRange[1], projectMaxBars)); + + // If range changed, update it + if (validatedStart !== currentRange[0] || validatedEnd !== currentRange[1]) { + newLoopingRange = [validatedStart, validatedEnd] as [number, number]; + if (DEBUG_MODE.TOOLBAR) { + console.log("Loop range clamped to valid range:", newLoopingRange); + } + } + } + } + + // Update project model + project.setIsLooping(newLoopingState); + project.setLoopingRange(newLoopingRange); + + // Update store to trigger UI re-render + useProjectStore.setState({ isLooping: newLoopingState, loopingRange: newLoopingRange }); + + if (DEBUG_MODE.TOOLBAR) { + console.log("Loop toggle clicked, isLooping:", newLoopingState, "range:", newLoopingRange); + } + }; + // Prompt to change max bars when clicking on current-time display const handleCurrentTimeClick = () => { const MIN_BARS = 16; @@ -740,6 +785,13 @@ const Toolbar: React.FC = () => { ) : ( )} +
{/* diff --git a/src/constants/uiConstants.ts b/src/constants/uiConstants.ts index 3cf7702..6eb9ded 100644 --- a/src/constants/uiConstants.ts +++ b/src/constants/uiConstants.ts @@ -51,3 +51,9 @@ export const PLAYING_CONSTANTS = { // Update rate for playback (10 FPS for performance evaluation) UPDATE_INTERVAL_MS: 100, // 1000ms / 10fps = 100ms }; + +// Bar numbers related constants +export const BAR_NUMBERS_CONSTANTS = { + // Minimum drag distance (in pixels) to consider as drag vs click + DRAG_THRESHOLD: 3, +}; diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index 6cf07a8..76a24bd 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -35,6 +35,14 @@ export class KGProject { @WithDefault("ionian") private selectedMode: string = "ionian"; + @Expose() + @WithDefault(false) + private isLooping: boolean = false; + + @Expose() + @WithDefault([0, 0]) + private loopingRange: [number, number] = [0, 0]; // [startBar, endBar] - bar indices (0-based) + @Expose() @WithDefault(0) private projectStructureVersion: number = 0; @@ -54,7 +62,7 @@ export class KGProject { private tracks: KGTrack[] = []; // Constructor - constructor(name: string = "Untitled Project", maxBars: number = 32, currentBars: number = 0, bpm: number = 125, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) { + constructor(name: string = "Untitled Project", 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], tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION) { this.name = name; this.maxBars = maxBars; this.currentBars = currentBars; @@ -62,6 +70,8 @@ export class KGProject { this.timeSignature = timeSignature; this.keySignature = keySignature; this.selectedMode = selectedMode; + this.isLooping = isLooping; + this.loopingRange = loopingRange; this.tracks = tracks; this.projectStructureVersion = projectStructureVersion; } @@ -139,5 +149,21 @@ export class KGProject { public getProjectStructureVersion(): number { return this.projectStructureVersion; } + + public getIsLooping(): boolean { + return this.isLooping; + } + + public setIsLooping(isLooping: boolean): void { + this.isLooping = isLooping; + } + + public getLoopingRange(): [number, number] { + return this.loopingRange; + } + + public setLoopingRange(loopingRange: [number, number]): void { + this.loopingRange = loopingRange; + } } diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 4804ec3..a4e5df4 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -40,6 +40,8 @@ interface ProjectState { bpm: number; keySignature: KeySignature; selectedMode: string; + isLooping: boolean; + loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based) playheadPosition: number; // in beats isPlaying: boolean; currentTime: string; // formatted time string @@ -225,6 +227,8 @@ export const useProjectStore = create((set, get) => { bpm: currentProject.getBpm(), keySignature: currentProject.getKeySignature(), selectedMode: currentProject.getSelectedMode(), + isLooping: currentProject.getIsLooping(), + loopingRange: currentProject.getLoopingRange(), playheadPosition: KGCore.instance().getPlayheadPosition(), isPlaying: KGCore.instance().getIsPlaying(), currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), @@ -519,6 +523,8 @@ export const useProjectStore = create((set, get) => { bpm, keySignature, selectedMode: projectToLoad.getSelectedMode(), + isLooping: projectToLoad.getIsLooping(), + loopingRange: projectToLoad.getLoopingRange(), playheadPosition: 0, // Ensure store state is also updated currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display }); diff --git a/src/test/utils/mock-data.ts b/src/test/utils/mock-data.ts index 247021e..bf3062f 100644 --- a/src/test/utils/mock-data.ts +++ b/src/test/utils/mock-data.ts @@ -121,6 +121,8 @@ export const createMockProject = (overrides: Partial<{ defaults.timeSignature, 'C major', // keySignature 'ionian', // selectedMode + false, // isLooping + [0, 0], // loopingRange defaults.tracks, // tracks 1 // projectStructureVersion )