diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 28eefc5..00bc552 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react'; import './MainContent.css'; import { createPortal } from 'react-dom'; import { useProjectStore } from '../stores/projectStore'; @@ -10,7 +10,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, BAR_NUMBERS_CONSTANTS } from '../constants'; +import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS, TOOLBAR_CONSTANTS } from '../constants'; import { useRegionOperations } from '../hooks/useRegionOperations'; import { regionDeleteManager } from '../util/regionDeleteUtil'; import { KGMainContentState } from '../core/state/KGMainContentState'; @@ -26,6 +26,7 @@ const MainContent: React.FC = ({ const { tracks, maxBars, + barWidthMultiplier, reorderTracks, updateTrack, updateTrackProperties, @@ -96,6 +97,7 @@ const MainContent: React.FC = ({ const mainContentRef = useRef(null); const expectedScrollLeftRef = useRef(-1); const isPlayingRef = useRef(false); + const previousBarWidthMultiplierRef = useRef(barWidthMultiplier); // Refs for bar numbers and loop range drag functionality const barNumbersRef = useRef(null); @@ -109,6 +111,37 @@ const MainContent: React.FC = ({ isPlayingRef.current = isPlaying; }, [isPlaying]); + useLayoutEffect(() => { + const previousMultiplier = previousBarWidthMultiplierRef.current; + if (previousMultiplier === barWidthMultiplier) return; + + previousBarWidthMultiplierRef.current = barWidthMultiplier; + + const container = mainContentRef.current; + if (!container) return; + + const infoWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width') + ) || 200; + const visibleMusicWidth = Math.max(0, container.clientWidth - infoWidth); + const previousBarWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * previousMultiplier; + const nextBarWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier; + + if (visibleMusicWidth === 0 || previousBarWidth === 0 || nextBarWidth === 0) return; + + const centerPixelBeforeZoom = container.scrollLeft + visibleMusicWidth / 2; + const anchorBeat = (centerPixelBeforeZoom / previousBarWidth) * timeSignature.numerator; + const targetPixel = (anchorBeat / timeSignature.numerator) * nextBarWidth; + const targetScrollLeft = targetPixel - visibleMusicWidth / 2; + const clampedScrollLeft = Math.max( + 0, + Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth) + ); + + expectedScrollLeftRef.current = clampedScrollLeft; + container.scrollLeft = clampedScrollLeft; + }, [barWidthMultiplier, timeSignature]); + // Detect manual horizontal scroll during playback useEffect(() => { const container = mainContentRef.current; diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 1f53d01..144eabf 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -1,11 +1,11 @@ -import React, { useRef, useEffect, useState, useCallback } from 'react'; +import React, { useRef, useEffect, useState, useCallback, useLayoutEffect } from 'react'; import './PianoRoll.css'; import type { MouseEvent } from 'react'; import { useProjectStore } from '../../stores/projectStore'; import { FaGripLines } from 'react-icons/fa'; import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; -import { DEBUG_MODE, PIANO_ROLL_CONSTANTS } from '../../constants'; +import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, TOOLBAR_CONSTANTS } from '../../constants'; import PianoRollHeader from './PianoRollHeader'; import PianoRollToolbar from './PianoRollToolbar'; import PianoRollContent from './PianoRollContent'; @@ -81,6 +81,7 @@ const PianoRoll: React.FC = ({ // Refs for auto-scroll during playback const pianoRollExpectedScrollLeftRef = useRef(-1); const pianoRollIsPlayingRef = useRef(false); + const pendingZoomAnchorBeatRef = useRef(null); // Ref for storing the setNoteUpdateCounter function const triggerNoteUpdateRef = useRef> | null>(null); @@ -613,6 +614,30 @@ const PianoRoll: React.FC = ({ } }, [quantizeSelectedNotes, quantizeNoteLength]); + const handleZoomChange = useCallback((nextZoom: number) => { + if (nextZoom === pianoRollZoom) return; + + const container = pianoRollContentRef.current; + pendingZoomAnchorBeatRef.current = null; + if (container) { + const keysWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') + ) || 60; + const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth); + const beatWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') + ) || TOOLBAR_CONSTANTS.BASE_BAR_WIDTH; + + if (visibleMusicWidth > 0 && beatWidth > 0) { + pendingZoomAnchorBeatRef.current = (container.scrollLeft + visibleMusicWidth / 2) / beatWidth; + } else { + pendingZoomAnchorBeatRef.current = null; + } + } + + setPianoRollZoom(nextZoom); + }, [pianoRollZoom]); + // Calculate C4 position and scroll to it when piano roll opens useEffect(() => { if (pianoRollContentRef.current) { @@ -717,12 +742,33 @@ const PianoRoll: React.FC = ({ useProjectStore.setState({ pianoRollScrollRequest: null }); }, [pianoRollScrollRequest]); - // Update --region-grid-beat-width when zoom changes; reset on unmount - useEffect(() => { - document.documentElement.style.setProperty('--region-grid-beat-width', `${40 * pianoRollZoom}px`); + // Update --region-grid-beat-width when zoom changes and preserve the centered beat position. + useLayoutEffect(() => { + const beatWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * pianoRollZoom; + document.documentElement.style.setProperty('--region-grid-beat-width', `${beatWidth}px`); if (triggerNoteUpdateRef.current) { triggerNoteUpdateRef.current(prev => prev + 1); } + + const anchorBeat = pendingZoomAnchorBeatRef.current; + const container = pianoRollContentRef.current; + if (anchorBeat !== null && container) { + const keysWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') + ) || 60; + const visibleMusicWidth = Math.max(0, container.clientWidth - keysWidth); + const targetPixel = anchorBeat * beatWidth; + const targetScrollLeft = targetPixel - visibleMusicWidth / 2; + const clampedScrollLeft = Math.max( + 0, + Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth) + ); + + pianoRollExpectedScrollLeftRef.current = clampedScrollLeft; + container.scrollLeft = clampedScrollLeft; + pendingZoomAnchorBeatRef.current = null; + } + return () => { document.documentElement.style.setProperty('--region-grid-beat-width', '40px'); }; @@ -970,7 +1016,7 @@ const PianoRoll: React.FC = ({ power={spectrogramPower} onPowerChange={setSpectrogramPower} zoom={pianoRollZoom} - onZoomChange={setPianoRollZoom} + onZoomChange={handleZoomChange} /> = ({ ); }; -export default PianoRoll; \ No newline at end of file +export default PianoRoll;