From d94b53dcf502e51959d4fc938184c30b842e45e6 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:57:34 -0700 Subject: [PATCH] feat: add snap-to-grid toggle for track grid regions and playhead --- src/components/MainContent.tsx | 8 +++++--- src/components/Toolbar.tsx | 24 +++++++++++++++++++++--- src/components/track/RegionItem.tsx | 2 +- src/components/track/TrackGridItem.tsx | 22 +++++++++++++--------- src/components/track/TrackGridPanel.tsx | 7 +++++-- src/core/state/KGMainContentState.ts | 9 +++++++++ 6 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index c56e09c..8ef912d 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -13,6 +13,7 @@ import type { RegionUI } from './interfaces'; import { DEBUG_MODE, BAR_NUMBERS_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 { @@ -135,7 +136,7 @@ const MainContent: React.FC = ({ if (region instanceof KGMidiRegion || region instanceof KGAudioRegion) { // Calculate bar number and length from beats const beatsPerBar = timeSignature.numerator; - const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1; + const barNumber = (region.getStartFromBeat() / beatsPerBar) + 1; const length = region.getLength() / beatsPerBar; // Create a RegionUI object @@ -517,8 +518,9 @@ const MainContent: React.FC = ({ getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width') ) || 40; - // Find the closest bar start (using Math.round for nearest bar) - const barIndex = Math.round(relativeX / barWidth); + // 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); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index ab71b56..a754aa2 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -12,7 +12,7 @@ import { FaUndo, FaRedo, FaMousePointer, FaStepBackward, FaPlay, FaPause, FaComments, FaSync, FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus, - FaCog + FaCog, FaMagnet } from 'react-icons/fa'; import { KGProject, type KeySignature } from '../core/KGProject'; import { plainToInstance } from 'class-transformer'; @@ -48,6 +48,7 @@ const Toolbar: React.FC = () => { // State for main content tools const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer'); + const [isSnapping, setIsSnapping] = React.useState(true); // State for key signature dropdown const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false); @@ -569,6 +570,16 @@ const Toolbar: React.FC = () => { } }; + // Handle snapping toggle + const handleSnappingToggle = () => { + const newValue = !isSnapping; + setIsSnapping(newValue); + KGMainContentState.instance().setSnapping(newValue); + if (DEBUG_MODE.TOOLBAR) { + console.log(`Snapping ${newValue ? 'enabled' : 'disabled'}`); + } + }; + // Handle copy button click const handleCopyClick = () => { if (DEBUG_MODE.TOOLBAR) { @@ -774,13 +785,20 @@ const Toolbar: React.FC = () => { > - +
diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx index 8091236..7930edb 100644 --- a/src/components/track/RegionItem.tsx +++ b/src/components/track/RegionItem.tsx @@ -307,7 +307,7 @@ const RegionItem: React.FC = ({ } else { renderNotesOnCanvas(); } - }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger]); + }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]); // Re-render canvas when region content size changes useEffect(() => { diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index 96b8004..3b518e8 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -268,20 +268,23 @@ const TrackGridItem: React.FC = ({ // If the mouse was moved and we have current values, calculate the new values if (mouseMoved.current && currentResizeWidth.current !== null && currentResizeLeft.current !== null) { + const snap = KGMainContentState.instance().isSnappingEnabled(); + if (resizeAction === 'end') { - // End resize: round length to nearest bar - newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, Math.round(currentResizeWidth.current / barWidth)); + // End resize: snap length to nearest bar, or use raw value + const rawLength = currentResizeWidth.current / barWidth; + newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, snap ? Math.round(rawLength) : rawLength); } else if (resizeAction === 'start') { - // Start resize: round bar number and adjust length accordingly + // Start resize: snap bar number, or use raw value const rawBarNumber = currentResizeLeft.current / barWidth + 1; - newBarNumber = Math.max(1, Math.round(rawBarNumber)); - + newBarNumber = Math.max(1, snap ? Math.round(rawBarNumber) : rawBarNumber); + // Calculate the difference from the initial position const barDiff = initialBarNumberRef.current! - newBarNumber; - + // Adjust length to maintain the end position newLength = initialLengthRef.current! + barDiff; - + // Ensure minimum length if (newLength < REGION_CONSTANTS.MIN_REGION_LENGTH) { newLength = REGION_CONSTANTS.MIN_REGION_LENGTH; @@ -434,9 +437,10 @@ const TrackGridItem: React.FC = ({ // If the mouse was moved, calculate the final position if (mouseMoved.current && currentDragLeft.current !== null && currentDragTop.current !== null) { - // Calculate the new bar number and round to nearest integer + // Calculate the new bar number; snap to nearest integer when snapping is on + const snap = KGMainContentState.instance().isSnappingEnabled(); const rawBarNumber = (currentDragLeft.current / barWidth) + 1; - finalBarNumber = Math.max(1, Math.round(rawBarNumber)); + finalBarNumber = Math.max(1, snap ? Math.round(rawBarNumber) : rawBarNumber); // Calculate the closest track based on vertical position if (allTracks && allTracks.length > 0 && gridContainerRef.current) { diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index 97cc8c7..dad1391 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -193,6 +193,7 @@ const TrackGridPanel: React.FC = ({ const secondsPerBeat = 60 / bpm; const clipOffset = coreRegion.getClipStartOffsetSeconds(); const audioDuration = coreRegion.getAudioDurationSeconds(); + const snap = KGMainContentState.instance().isSnappingEnabled(); // Left edge changed — calculate new clip offset if (clampedBarNumber !== oldBarNumber) { @@ -205,7 +206,9 @@ const TrackGridPanel: React.FC = ({ // Dragged past audio start — snap to earliest allowed position const maxLeftExtensionBeats = clipOffset / secondsPerBeat; const minStartBeat = oldStartBeat - maxLeftExtensionBeats; - clampedBarNumber = Math.ceil(minStartBeat / beatsPerBar) + 1; + clampedBarNumber = snap + ? Math.ceil(minStartBeat / beatsPerBar) + 1 + : (minStartBeat / beatsPerBar) + 1; const oldEndBarNumber = oldBarNumber + (coreRegion.getLength() / beatsPerBar); clampedLength = oldEndBarNumber - clampedBarNumber; newClipStartOffsetSeconds = 0; @@ -219,7 +222,7 @@ const TrackGridPanel: React.FC = ({ const maxDurationSeconds = audioDuration - effectiveClipOffset; const maxLengthBars = (maxDurationSeconds / secondsPerBeat) / beatsPerBar; if (clampedLength > maxLengthBars) { - clampedLength = Math.floor(maxLengthBars); + clampedLength = snap ? Math.floor(maxLengthBars) : maxLengthBars; if (clampedLength < REGION_CONSTANTS.MIN_REGION_LENGTH) { clampedLength = REGION_CONSTANTS.MIN_REGION_LENGTH; } diff --git a/src/core/state/KGMainContentState.ts b/src/core/state/KGMainContentState.ts index 8350f7f..ca38d8f 100644 --- a/src/core/state/KGMainContentState.ts +++ b/src/core/state/KGMainContentState.ts @@ -6,6 +6,7 @@ export class KGMainContentState { private static _instance: KGMainContentState | null = null; private activeTool: string = "pointer"; + private snapping: boolean = true; private constructor() { console.log("KGMainContentState initialized"); @@ -26,4 +27,12 @@ export class KGMainContentState { public setActiveTool(tool: string): void { this.activeTool = tool; } + + public isSnappingEnabled(): boolean { + return this.snapping; + } + + public setSnapping(enabled: boolean): void { + this.snapping = enabled; + } } \ No newline at end of file