feat: implemented UI of looping feature

This commit is contained in:
Xiaohan-Tian
2026-01-22 18:36:25 -08:00
parent 20310ccea0
commit f367c84ee8
7 changed files with 223 additions and 72 deletions
+5
View File
@@ -268,6 +268,11 @@ body {
box-sizing: border-box; box-sizing: border-box;
} }
.bar-number-cell.looped {
background-color: #e1ae01;
color: #1e1e1e;
}
.main-content-body { .main-content-body {
display: flex; display: flex;
min-height: fit-content; min-height: fit-content;
+109 -55
View File
@@ -8,7 +8,7 @@ import TrackInfoPanel from './track/TrackInfoPanel';
import TrackGridPanel from './track/TrackGridPanel'; import TrackGridPanel from './track/TrackGridPanel';
import PianoRoll from './piano-roll/PianoRoll'; import PianoRoll from './piano-roll/PianoRoll';
import type { RegionUI } from './interfaces'; import type { RegionUI } from './interfaces';
import { DEBUG_MODE } from '../constants'; import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } from '../constants';
import { useRegionOperations } from '../hooks/useRegionOperations'; import { useRegionOperations } from '../hooks/useRegionOperations';
import { regionDeleteManager } from '../util/regionDeleteUtil'; import { regionDeleteManager } from '../util/regionDeleteUtil';
@@ -73,9 +73,11 @@ const MainContent: React.FC<MainContentProps> = ({
// 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());
// Refs for bar numbers drag functionality // Refs for bar numbers and loop range drag functionality
const isDraggingRef = useRef(false);
const barNumbersRef = useRef<HTMLDivElement | null>(null); const barNumbersRef = useRef<HTMLDivElement | null>(null);
const isLoopDraggingRef = useRef(false);
const loopDragStartBarRef = useRef<number | null>(null);
const loopDragStartXRef = useRef<number | null>(null);
// Effect to verify track updates // Effect to verify track updates
useEffect(() => { useEffect(() => {
@@ -515,75 +517,115 @@ const MainContent: React.FC<MainContentProps> = ({
return destinationBeatPosition; return destinationBeatPosition;
}, [timeSignature]); }, [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<HTMLDivElement>) => { const handleBarNumbersMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// Only handle left mouse button // Only handle left mouse button
if (e.button !== 0) return; if (e.button !== 0) return;
isDraggingRef.current = true; // Calculate starting bar index
const startBarIndex = calculateBarIndexFromMouse(e.clientX);
if (startBarIndex === null) return;
// Calculate and set initial playhead position // Always start loop drag tracking
const newPosition = calculatePlayheadFromMouse(e.clientX); isLoopDraggingRef.current = true;
if (newPosition !== null) { loopDragStartBarRef.current = startBarIndex;
setPlayheadPosition(newPosition); loopDragStartXRef.current = e.clientX;
if (DEBUG_MODE.MAIN_CONTENT) { if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Bar numbers drag started - Initial position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`); console.log(`Bar numbers mouse down - Start bar: ${startBarIndex} (displayed as bar ${startBarIndex + 1})`);
}
} }
// Prevent text selection during drag // Prevent text selection during drag
e.preventDefault(); e.preventDefault();
}; };
// Handle click on bar numbers to move playhead (when not dragging) // Global mouse move and mouse up handlers for loop range drag functionality
const handleBarNumbersClick = (e: React.MouseEvent<HTMLDivElement>) => {
// 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
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return; if (!isLoopDraggingRef.current) return;
if (loopDragStartBarRef.current === null || loopDragStartXRef.current === null) return;
const newPosition = calculatePlayheadFromMouse(e.clientX); // Calculate distance moved
if (newPosition !== null) { const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current);
setPlayheadPosition(newPosition);
if (DEBUG_MODE.MAIN_CONTENT) { // Only update if moved beyond threshold
console.log(`Bar numbers drag - Position: ${newPosition} (bar ${Math.floor(newPosition / timeSignature.numerator) + 1})`); 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 = () => { const handleMouseUp = (e: MouseEvent) => {
if (isDraggingRef.current) { if (isLoopDraggingRef.current) {
isDraggingRef.current = false; if (loopDragStartXRef.current !== null) {
const distanceMoved = Math.abs(e.clientX - loopDragStartXRef.current);
if (DEBUG_MODE.MAIN_CONTENT) { // If dragged beyond threshold, enable looping
console.log('Bar numbers drag ended'); 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,9 +638,17 @@ const MainContent: React.FC<MainContentProps> = ({
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); 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 ( return (
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}> <div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
@@ -611,10 +661,14 @@ const MainContent: React.FC<MainContentProps> = ({
className="bar-numbers" className="bar-numbers"
ref={barNumbersRef} ref={barNumbersRef}
onMouseDown={handleBarNumbersMouseDown} onMouseDown={handleBarNumbersMouseDown}
onClick={handleBarNumbersClick}
> >
{Array.from({ length: maxBars }, (_, i) => ( {Array.from({ length: maxBars }, (_, i) => (
<div key={i} className="bar-number-cell">{i + 1}</div> <div
key={i}
className={`bar-number-cell${isBarInLoopRange(i) ? ' looped' : ''}`}
>
{i + 1}
</div>
))} ))}
</div> </div>
+53 -1
View File
@@ -9,7 +9,7 @@ import { TIME_CONSTANTS } from '../constants/coreConstants';
import { parseTimeSignature, getTimeSignatureErrorMessage } from '../util/timeUtil'; import { parseTimeSignature, getTimeSignatureErrorMessage } from '../util/timeUtil';
import { import {
FaUndo, FaRedo, FaMousePointer, FaStepBackward, FaUndo, FaRedo, FaMousePointer, FaStepBackward,
FaPlay, FaPause, FaComments, FaPlay, FaPause, FaComments, FaSync,
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus, FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
FaCog FaCog
} from 'react-icons/fa'; } from 'react-icons/fa';
@@ -33,6 +33,7 @@ const Toolbar: React.FC = () => {
isPlaying, startPlaying, stopPlaying, setPlayheadPosition, isPlaying, startPlaying, stopPlaying, setPlayheadPosition,
currentTime, setBpm, setTimeSignature, setKeySignature, currentTime, setBpm, setTimeSignature, setKeySignature,
maxBars, setMaxBars, maxBars, setMaxBars,
isLooping, loopingRange,
canUndo, canRedo, undoDescription, redoDescription, undo, redo, canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, cleanupProjectState, toggleChatBox, toggleSettings, cleanupProjectState,
// Piano roll state/actions // Piano roll state/actions
@@ -412,6 +413,50 @@ const Toolbar: React.FC = () => {
setPlayheadPosition(0); 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 // Prompt to change max bars when clicking on current-time display
const handleCurrentTimeClick = () => { const handleCurrentTimeClick = () => {
const MIN_BARS = 16; const MIN_BARS = 16;
@@ -740,6 +785,13 @@ const Toolbar: React.FC = () => {
) : ( ) : (
<button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button> <button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button>
)} )}
<button
title="Loop"
className={`tool-button ${isLooping ? 'active' : ''}`}
onClick={handleLoopToggle}
>
<FaSync />
</button>
<div className="toolbar-separator"></div> <div className="toolbar-separator"></div>
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button> <button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
{/* <button title="Record"><FaCircle className="record-btn" /></button> {/* <button title="Record"><FaCircle className="record-btn" /></button>
+6
View File
@@ -51,3 +51,9 @@ export const PLAYING_CONSTANTS = {
// Update rate for playback (10 FPS for performance evaluation) // Update rate for playback (10 FPS for performance evaluation)
UPDATE_INTERVAL_MS: 100, // 1000ms / 10fps = 100ms 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,
};
+27 -1
View File
@@ -35,6 +35,14 @@ export class KGProject {
@WithDefault("ionian") @WithDefault("ionian")
private selectedMode: string = "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() @Expose()
@WithDefault(0) @WithDefault(0)
private projectStructureVersion: number = 0; private projectStructureVersion: number = 0;
@@ -54,7 +62,7 @@ export class KGProject {
private tracks: KGTrack[] = []; private tracks: KGTrack[] = [];
// Constructor // 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.name = name;
this.maxBars = maxBars; this.maxBars = maxBars;
this.currentBars = currentBars; this.currentBars = currentBars;
@@ -62,6 +70,8 @@ export class KGProject {
this.timeSignature = timeSignature; this.timeSignature = timeSignature;
this.keySignature = keySignature; this.keySignature = keySignature;
this.selectedMode = selectedMode; this.selectedMode = selectedMode;
this.isLooping = isLooping;
this.loopingRange = loopingRange;
this.tracks = tracks; this.tracks = tracks;
this.projectStructureVersion = projectStructureVersion; this.projectStructureVersion = projectStructureVersion;
} }
@@ -139,5 +149,21 @@ export class KGProject {
public getProjectStructureVersion(): number { public getProjectStructureVersion(): number {
return this.projectStructureVersion; 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;
}
} }
+6
View File
@@ -40,6 +40,8 @@ interface ProjectState {
bpm: number; bpm: number;
keySignature: KeySignature; keySignature: KeySignature;
selectedMode: string; selectedMode: string;
isLooping: boolean;
loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based)
playheadPosition: number; // in beats playheadPosition: number; // in beats
isPlaying: boolean; isPlaying: boolean;
currentTime: string; // formatted time string currentTime: string; // formatted time string
@@ -225,6 +227,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
bpm: currentProject.getBpm(), bpm: currentProject.getBpm(),
keySignature: currentProject.getKeySignature(), keySignature: currentProject.getKeySignature(),
selectedMode: currentProject.getSelectedMode(), selectedMode: currentProject.getSelectedMode(),
isLooping: currentProject.getIsLooping(),
loopingRange: currentProject.getLoopingRange(),
playheadPosition: KGCore.instance().getPlayheadPosition(), playheadPosition: KGCore.instance().getPlayheadPosition(),
isPlaying: KGCore.instance().getIsPlaying(), isPlaying: KGCore.instance().getIsPlaying(),
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
@@ -519,6 +523,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
bpm, bpm,
keySignature, keySignature,
selectedMode: projectToLoad.getSelectedMode(), selectedMode: projectToLoad.getSelectedMode(),
isLooping: projectToLoad.getIsLooping(),
loopingRange: projectToLoad.getLoopingRange(),
playheadPosition: 0, // Ensure store state is also updated playheadPosition: 0, // Ensure store state is also updated
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
}); });
+2
View File
@@ -121,6 +121,8 @@ export const createMockProject = (overrides: Partial<{
defaults.timeSignature, defaults.timeSignature,
'C major', // keySignature 'C major', // keySignature
'ionian', // selectedMode 'ionian', // selectedMode
false, // isLooping
[0, 0], // loopingRange
defaults.tracks, // tracks defaults.tracks, // tracks
1 // projectStructureVersion 1 // projectStructureVersion
) )