From cb027b6c2048addaa75a9616f7a30565c73811cb Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:53:01 -0700 Subject: [PATCH] feat: added auto-center playhead to piano roll window --- src/components/MainContent.tsx | 15 +++---- src/components/piano-roll/PianoRoll.tsx | 54 ++++++++++++++++++++++++- src/stores/projectStore.ts | 9 ++++- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index dece0b7..d5cce65 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -33,6 +33,8 @@ const MainContent: React.FC = ({ setPlayheadPosition, playheadPosition, isPlaying, + autoScrollEnabled, + setAutoScrollEnabled, clearAllSelections, setSelectedTrack, showPianoRoll, @@ -84,7 +86,6 @@ const MainContent: React.FC = ({ // Refs for auto-scroll during playback const mainContentRef = useRef(null); - const userManuallyScrolledRef = useRef(false); const expectedScrollLeftRef = useRef(-1); const isPlayingRef = useRef(false); @@ -95,11 +96,8 @@ const MainContent: React.FC = ({ const loopDragStartXRef = useRef(null); const loopDragOriginalSettingsRef = useRef<{ isLooping: boolean; loopingRange: [number, number] } | null>(null); - // Sync isPlayingRef and reset manual-scroll flag when playback starts + // Sync isPlayingRef for use inside scroll event closure useEffect(() => { - if (isPlaying && !isPlayingRef.current) { - userManuallyScrolledRef.current = false; - } isPlayingRef.current = isPlaying; }, [isPlaying]); @@ -110,9 +108,8 @@ const MainContent: React.FC = ({ const handleScroll = () => { if (!isPlayingRef.current) return; - // If scrollLeft matches what we programmatically set (within 1px), it's our scroll — ignore if (Math.abs(container.scrollLeft - expectedScrollLeftRef.current) < 1) return; - userManuallyScrolledRef.current = true; + useProjectStore.getState().setAutoScrollEnabled(false); }; container.addEventListener('scroll', handleScroll); @@ -121,7 +118,7 @@ const MainContent: React.FC = ({ // Auto-scroll to keep playhead centered during playback useEffect(() => { - if (!isPlaying || userManuallyScrolledRef.current) return; + if (!isPlaying || !autoScrollEnabled) return; const container = mainContentRef.current; if (!container) return; @@ -143,7 +140,7 @@ const MainContent: React.FC = ({ expectedScrollLeftRef.current = clampedScrollLeft; container.scrollLeft = clampedScrollLeft; - }, [playheadPosition, isPlaying, timeSignature]); + }, [playheadPosition, isPlaying, autoScrollEnabled, timeSignature]); // Effect to verify track updates useEffect(() => { diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index b60ee28..03cc45c 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -29,7 +29,7 @@ const PianoRoll: React.FC = ({ initialPosition, initialSize }) => { - const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode } = useProjectStore(); + const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled } = useProjectStore(); // Tool state for piano roll const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); @@ -58,7 +58,11 @@ const PianoRoll: React.FC = ({ const pianoRollContentRef = useRef(null); const pianoGridRef = useRef(null); const wasDraggingRef = useRef(false); - + + // Refs for auto-scroll during playback + const pianoRollExpectedScrollLeftRef = useRef(-1); + const pianoRollIsPlayingRef = useRef(false); + // Ref for storing the setNoteUpdateCounter function const triggerNoteUpdateRef = useRef> | null>(null); @@ -621,6 +625,52 @@ const PianoRoll: React.FC = ({ } }, []); + // Sync isPlayingRef for use inside scroll event closure + useEffect(() => { + pianoRollIsPlayingRef.current = isPlaying; + }, [isPlaying]); + + // Detect manual horizontal scroll during playback + useEffect(() => { + const container = pianoRollContentRef.current; + if (!container) return; + + const handleScroll = () => { + if (!pianoRollIsPlayingRef.current) return; + if (Math.abs(container.scrollLeft - pianoRollExpectedScrollLeftRef.current) < 1) return; + useProjectStore.getState().setAutoScrollEnabled(false); + }; + + container.addEventListener('scroll', handleScroll); + return () => container.removeEventListener('scroll', handleScroll); + }, []); + + // Auto-scroll to keep playhead centered during playback + useEffect(() => { + if (!isPlaying || !autoScrollEnabled) return; + + const container = pianoRollContentRef.current; + if (!container) return; + + const beatWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width') + ) || 40; + const playheadPixel = playheadPosition * beatWidth; + + // Center the playhead in the visible grid area (excluding the 60px sticky piano keys panel) + const keysWidth = parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') + ) || 60; + const targetScrollLeft = playheadPixel - (container.clientWidth - keysWidth) / 2; + const clampedScrollLeft = Math.max( + 0, + Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth) + ); + + pianoRollExpectedScrollLeftRef.current = clampedScrollLeft; + container.scrollLeft = clampedScrollLeft; + }, [playheadPosition, isPlaying, autoScrollEnabled]); + // Scroll horizontally to the active region's starting bar useEffect(() => { if (pianoRollContentRef.current && activeRegion) { diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 4a8db48..1756bf8 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -62,6 +62,7 @@ interface ProjectState { loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based) playheadPosition: number; // in beats isPlaying: boolean; + autoScrollEnabled: boolean; currentTime: string; // formatted time string // Selection state for UI reactivity @@ -114,6 +115,7 @@ interface ProjectState { refreshStatus: () => void; loadProject: (project: KGProject | null, savedName?: string) => Promise; setPlayheadPosition: (position: number) => void; + setAutoScrollEnabled: (enabled: boolean) => void; startPlaying: () => Promise; stopPlaying: () => Promise; toggleLoop: () => void; @@ -270,6 +272,7 @@ export const useProjectStore = create((set, get) => { loopingRange: currentProject.getLoopingRange(), playheadPosition: KGCore.instance().getPlayheadPosition(), isPlaying: KGCore.instance().getIsPlaying(), + autoScrollEnabled: true, currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), // Initial selection state @@ -752,9 +755,13 @@ export const useProjectStore = create((set, get) => { }); }, + setAutoScrollEnabled: (enabled: boolean) => { + set({ autoScrollEnabled: enabled }); + }, + startPlaying: async () => { await KGCore.instance().startPlaying(); - set({ isPlaying: true }); + set({ isPlaying: true, autoScrollEnabled: true }); }, stopPlaying: async () => {