feat: preserve the viewport position when zooming in and out

This commit is contained in:
Xiaohan-Tian
2026-05-02 13:35:17 -07:00
parent da8b9496df
commit f132f6bbe2
2 changed files with 88 additions and 9 deletions
+35 -2
View File
@@ -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 './MainContent.css';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
@@ -10,7 +10,7 @@ import TrackInfoPanel from './track/TrackInfoPanel';
import TrackGridPanel from './track/TrackGridPanel'; import TrackGridPanel from './track/TrackGridPanel';
import PianoRoll from './piano-roll/PianoRoll'; import PianoRoll from './piano-roll/PianoRoll';
import type { RegionUI } from './interfaces'; 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 { useRegionOperations } from '../hooks/useRegionOperations';
import { regionDeleteManager } from '../util/regionDeleteUtil'; import { regionDeleteManager } from '../util/regionDeleteUtil';
import { KGMainContentState } from '../core/state/KGMainContentState'; import { KGMainContentState } from '../core/state/KGMainContentState';
@@ -26,6 +26,7 @@ const MainContent: React.FC<MainContentProps> = ({
const { const {
tracks, tracks,
maxBars, maxBars,
barWidthMultiplier,
reorderTracks, reorderTracks,
updateTrack, updateTrack,
updateTrackProperties, updateTrackProperties,
@@ -96,6 +97,7 @@ const MainContent: React.FC<MainContentProps> = ({
const mainContentRef = useRef<HTMLDivElement | null>(null); const mainContentRef = useRef<HTMLDivElement | null>(null);
const expectedScrollLeftRef = useRef<number>(-1); const expectedScrollLeftRef = useRef<number>(-1);
const isPlayingRef = useRef(false); const isPlayingRef = useRef(false);
const previousBarWidthMultiplierRef = useRef(barWidthMultiplier);
// Refs for bar numbers and loop range drag functionality // Refs for bar numbers and loop range drag functionality
const barNumbersRef = useRef<HTMLDivElement | null>(null); const barNumbersRef = useRef<HTMLDivElement | null>(null);
@@ -109,6 +111,37 @@ const MainContent: React.FC<MainContentProps> = ({
isPlayingRef.current = isPlaying; isPlayingRef.current = isPlaying;
}, [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 // Detect manual horizontal scroll during playback
useEffect(() => { useEffect(() => {
const container = mainContentRef.current; const container = mainContentRef.current;
+53 -7
View File
@@ -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 './PianoRoll.css';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import { FaGripLines } from 'react-icons/fa'; import { FaGripLines } from 'react-icons/fa';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; 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 PianoRollHeader from './PianoRollHeader';
import PianoRollToolbar from './PianoRollToolbar'; import PianoRollToolbar from './PianoRollToolbar';
import PianoRollContent from './PianoRollContent'; import PianoRollContent from './PianoRollContent';
@@ -81,6 +81,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Refs for auto-scroll during playback // Refs for auto-scroll during playback
const pianoRollExpectedScrollLeftRef = useRef<number>(-1); const pianoRollExpectedScrollLeftRef = useRef<number>(-1);
const pianoRollIsPlayingRef = useRef(false); const pianoRollIsPlayingRef = useRef(false);
const pendingZoomAnchorBeatRef = useRef<number | null>(null);
// Ref for storing the setNoteUpdateCounter function // Ref for storing the setNoteUpdateCounter function
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null); const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
@@ -613,6 +614,30 @@ const PianoRoll: React.FC<PianoRollProps> = ({
} }
}, [quantizeSelectedNotes, quantizeNoteLength]); }, [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 // Calculate C4 position and scroll to it when piano roll opens
useEffect(() => { useEffect(() => {
if (pianoRollContentRef.current) { if (pianoRollContentRef.current) {
@@ -717,12 +742,33 @@ const PianoRoll: React.FC<PianoRollProps> = ({
useProjectStore.setState({ pianoRollScrollRequest: null }); useProjectStore.setState({ pianoRollScrollRequest: null });
}, [pianoRollScrollRequest]); }, [pianoRollScrollRequest]);
// Update --region-grid-beat-width when zoom changes; reset on unmount // Update --region-grid-beat-width when zoom changes and preserve the centered beat position.
useEffect(() => { useLayoutEffect(() => {
document.documentElement.style.setProperty('--region-grid-beat-width', `${40 * pianoRollZoom}px`); const beatWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * pianoRollZoom;
document.documentElement.style.setProperty('--region-grid-beat-width', `${beatWidth}px`);
if (triggerNoteUpdateRef.current) { if (triggerNoteUpdateRef.current) {
triggerNoteUpdateRef.current(prev => prev + 1); 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 () => { return () => {
document.documentElement.style.setProperty('--region-grid-beat-width', '40px'); document.documentElement.style.setProperty('--region-grid-beat-width', '40px');
}; };
@@ -970,7 +1016,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
power={spectrogramPower} power={spectrogramPower}
onPowerChange={setSpectrogramPower} onPowerChange={setSpectrogramPower}
zoom={pianoRollZoom} zoom={pianoRollZoom}
onZoomChange={setPianoRollZoom} onZoomChange={handleZoomChange}
/> />
<PianoRollContent <PianoRollContent
@@ -1006,4 +1052,4 @@ const PianoRoll: React.FC<PianoRollProps> = ({
); );
}; };
export default PianoRoll; export default PianoRoll;