feat: implemented UI of looping feature
This commit is contained in:
+119
-65
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user