feat: preserve the viewport position when zooming in and out
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user