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
+119 -65
View File
@@ -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<MainContentProps> = ({
// Refs to track pending updates for verification
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
// Refs for bar numbers drag functionality
const isDraggingRef = useRef(false);
// Refs for bar numbers and loop range drag functionality
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
useEffect(() => {
@@ -515,75 +517,115 @@ const MainContent: React.FC<MainContentProps> = ({
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<HTMLDivElement>) => {
// 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<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
// 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<MainContentProps> = ({
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 (
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
<div className="main-content-wrapper">
{/* Top-left spacer */}
<div className="top-left-spacer"></div>
{/* Bar numbers at the top */}
<div
className="bar-numbers"
<div
className="bar-numbers"
ref={barNumbersRef}
onMouseDown={handleBarNumbersMouseDown}
onClick={handleBarNumbersClick}
>
{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>
+58 -6
View File
@@ -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 = () => {
) : (
<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>
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
{/* <button title="Record"><FaCircle className="record-btn" /></button>