feat: add snap-to-grid toggle for track grid regions and playhead
This commit is contained in:
@@ -13,6 +13,7 @@ import type { RegionUI } from './interfaces';
|
|||||||
import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS } 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';
|
||||||
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
import { ChangeLoopSettingsCommand } from '../core/commands';
|
import { ChangeLoopSettingsCommand } from '../core/commands';
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
@@ -135,7 +136,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
if (region instanceof KGMidiRegion || region instanceof KGAudioRegion) {
|
if (region instanceof KGMidiRegion || region instanceof KGAudioRegion) {
|
||||||
// Calculate bar number and length from beats
|
// Calculate bar number and length from beats
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1;
|
const barNumber = (region.getStartFromBeat() / beatsPerBar) + 1;
|
||||||
const length = region.getLength() / beatsPerBar;
|
const length = region.getLength() / beatsPerBar;
|
||||||
|
|
||||||
// Create a RegionUI object
|
// Create a RegionUI object
|
||||||
@@ -517,8 +518,9 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||||
) || 40;
|
) || 40;
|
||||||
|
|
||||||
// Find the closest bar start (using Math.round for nearest bar)
|
// Find the closest bar start; honor snapping toggle
|
||||||
const barIndex = Math.round(relativeX / barWidth);
|
const snap = KGMainContentState.instance().isSnappingEnabled();
|
||||||
|
const barIndex = snap ? Math.round(relativeX / barWidth) : relativeX / barWidth;
|
||||||
|
|
||||||
// Ensure we don't go below 0
|
// Ensure we don't go below 0
|
||||||
const clampedBarIndex = Math.max(0, barIndex);
|
const clampedBarIndex = Math.max(0, barIndex);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
||||||
FaPlay, FaPause, FaComments, FaSync,
|
FaPlay, FaPause, FaComments, FaSync,
|
||||||
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
||||||
FaCog
|
FaCog, FaMagnet
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
@@ -48,6 +48,7 @@ const Toolbar: React.FC = () => {
|
|||||||
|
|
||||||
// State for main content tools
|
// State for main content tools
|
||||||
const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer');
|
const [activeMainTool, setActiveMainTool] = React.useState<'pointer' | 'pencil'>('pointer');
|
||||||
|
const [isSnapping, setIsSnapping] = React.useState(true);
|
||||||
|
|
||||||
// State for key signature dropdown
|
// State for key signature dropdown
|
||||||
const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false);
|
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
|
// Handle copy button click
|
||||||
const handleCopyClick = () => {
|
const handleCopyClick = () => {
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
@@ -781,6 +792,13 @@ const Toolbar: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FaPencil />
|
<FaPencil />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
title="Snap to Grid"
|
||||||
|
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
||||||
|
onClick={handleSnappingToggle}
|
||||||
|
>
|
||||||
|
<FaMagnet />
|
||||||
|
</button>
|
||||||
<div className="toolbar-separator"></div>
|
<div className="toolbar-separator"></div>
|
||||||
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button>
|
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button>
|
||||||
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button>
|
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button>
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
renderNotesOnCanvas();
|
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
|
// Re-render canvas when region content size changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -268,13 +268,16 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
|
|
||||||
// If the mouse was moved and we have current values, calculate the new values
|
// If the mouse was moved and we have current values, calculate the new values
|
||||||
if (mouseMoved.current && currentResizeWidth.current !== null && currentResizeLeft.current !== null) {
|
if (mouseMoved.current && currentResizeWidth.current !== null && currentResizeLeft.current !== null) {
|
||||||
|
const snap = KGMainContentState.instance().isSnappingEnabled();
|
||||||
|
|
||||||
if (resizeAction === 'end') {
|
if (resizeAction === 'end') {
|
||||||
// End resize: round length to nearest bar
|
// End resize: snap length to nearest bar, or use raw value
|
||||||
newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, Math.round(currentResizeWidth.current / barWidth));
|
const rawLength = currentResizeWidth.current / barWidth;
|
||||||
|
newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, snap ? Math.round(rawLength) : rawLength);
|
||||||
} else if (resizeAction === 'start') {
|
} 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;
|
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
|
// Calculate the difference from the initial position
|
||||||
const barDiff = initialBarNumberRef.current! - newBarNumber;
|
const barDiff = initialBarNumberRef.current! - newBarNumber;
|
||||||
@@ -434,9 +437,10 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
|
|
||||||
// If the mouse was moved, calculate the final position
|
// If the mouse was moved, calculate the final position
|
||||||
if (mouseMoved.current && currentDragLeft.current !== null && currentDragTop.current !== null) {
|
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;
|
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
|
// Calculate the closest track based on vertical position
|
||||||
if (allTracks && allTracks.length > 0 && gridContainerRef.current) {
|
if (allTracks && allTracks.length > 0 && gridContainerRef.current) {
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
const secondsPerBeat = 60 / bpm;
|
const secondsPerBeat = 60 / bpm;
|
||||||
const clipOffset = coreRegion.getClipStartOffsetSeconds();
|
const clipOffset = coreRegion.getClipStartOffsetSeconds();
|
||||||
const audioDuration = coreRegion.getAudioDurationSeconds();
|
const audioDuration = coreRegion.getAudioDurationSeconds();
|
||||||
|
const snap = KGMainContentState.instance().isSnappingEnabled();
|
||||||
|
|
||||||
// Left edge changed — calculate new clip offset
|
// Left edge changed — calculate new clip offset
|
||||||
if (clampedBarNumber !== oldBarNumber) {
|
if (clampedBarNumber !== oldBarNumber) {
|
||||||
@@ -205,7 +206,9 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
// Dragged past audio start — snap to earliest allowed position
|
// Dragged past audio start — snap to earliest allowed position
|
||||||
const maxLeftExtensionBeats = clipOffset / secondsPerBeat;
|
const maxLeftExtensionBeats = clipOffset / secondsPerBeat;
|
||||||
const minStartBeat = oldStartBeat - maxLeftExtensionBeats;
|
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);
|
const oldEndBarNumber = oldBarNumber + (coreRegion.getLength() / beatsPerBar);
|
||||||
clampedLength = oldEndBarNumber - clampedBarNumber;
|
clampedLength = oldEndBarNumber - clampedBarNumber;
|
||||||
newClipStartOffsetSeconds = 0;
|
newClipStartOffsetSeconds = 0;
|
||||||
@@ -219,7 +222,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
const maxDurationSeconds = audioDuration - effectiveClipOffset;
|
const maxDurationSeconds = audioDuration - effectiveClipOffset;
|
||||||
const maxLengthBars = (maxDurationSeconds / secondsPerBeat) / beatsPerBar;
|
const maxLengthBars = (maxDurationSeconds / secondsPerBeat) / beatsPerBar;
|
||||||
if (clampedLength > maxLengthBars) {
|
if (clampedLength > maxLengthBars) {
|
||||||
clampedLength = Math.floor(maxLengthBars);
|
clampedLength = snap ? Math.floor(maxLengthBars) : maxLengthBars;
|
||||||
if (clampedLength < REGION_CONSTANTS.MIN_REGION_LENGTH) {
|
if (clampedLength < REGION_CONSTANTS.MIN_REGION_LENGTH) {
|
||||||
clampedLength = REGION_CONSTANTS.MIN_REGION_LENGTH;
|
clampedLength = REGION_CONSTANTS.MIN_REGION_LENGTH;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export class KGMainContentState {
|
|||||||
private static _instance: KGMainContentState | null = null;
|
private static _instance: KGMainContentState | null = null;
|
||||||
|
|
||||||
private activeTool: string = "pointer";
|
private activeTool: string = "pointer";
|
||||||
|
private snapping: boolean = true;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
console.log("KGMainContentState initialized");
|
console.log("KGMainContentState initialized");
|
||||||
@@ -26,4 +27,12 @@ export class KGMainContentState {
|
|||||||
public setActiveTool(tool: string): void {
|
public setActiveTool(tool: string): void {
|
||||||
this.activeTool = tool;
|
this.activeTool = tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isSnappingEnabled(): boolean {
|
||||||
|
return this.snapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSnapping(enabled: boolean): void {
|
||||||
|
this.snapping = enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user