feat: add cross-component playhead scroll synchronization

This commit is contained in:
Xiaohan-Tian
2026-05-02 00:25:41 -07:00
parent 6b8a470022
commit da8b9496df
7 changed files with 94 additions and 11 deletions
+4 -4
View File
@@ -10,7 +10,7 @@
.main-content-wrapper { .main-content-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: calc(200px + var(--max-number-of-bars) * var(--track-grid-bar-width)); /* info width + grid width */ min-width: calc(var(--track-info-panel-width) + var(--max-number-of-bars) * var(--track-grid-bar-width)); /* info width + grid width */
min-height: fit-content; min-height: fit-content;
position: relative; position: relative;
} }
@@ -19,7 +19,7 @@
position: fixed; position: fixed;
top: 50px; top: 50px;
left: 0; left: 0;
width: 200px; width: var(--track-info-panel-width);
height: 20px; height: 20px;
background-color: #2d2d2d; background-color: #2d2d2d;
border-bottom: 1px solid #3a3a3a; border-bottom: 1px solid #3a3a3a;
@@ -42,7 +42,7 @@
border-bottom: 1px solid #3a3a3a; border-bottom: 1px solid #3a3a3a;
background-color: #2d2d2d; background-color: #2d2d2d;
z-index: 20; z-index: 20;
margin-left: 200px; /* Offset for info-container */ margin-left: var(--track-info-panel-width); /* Offset for info-container */
width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Exact width for 32 bars */ width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Exact width for 32 bars */
cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */ cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */
} }
@@ -80,7 +80,7 @@
.info-container { .info-container {
position: sticky; position: sticky;
left: 0; left: 0;
width: 200px; width: var(--track-info-panel-width);
z-index: 1002; z-index: 1002;
background-color: #2d2d2d; background-color: #2d2d2d;
align-self: flex-start; align-self: flex-start;
+38 -3
View File
@@ -50,6 +50,8 @@ const MainContent: React.FC<MainContentProps> = ({
addAudioTrack, addAudioTrack,
projectName, projectName,
savedProjectName, savedProjectName,
requestPianoRollScroll,
mainContentScrollRequest,
} = useProjectStore(); } = useProjectStore();
// State to store regions // State to store regions
@@ -136,8 +138,10 @@ const MainContent: React.FC<MainContentProps> = ({
) || 40; ) || 40;
const playheadPixel = barPosition * barWidth; const playheadPixel = barPosition * barWidth;
// Center the playhead in the visible grid area (excluding the 200px sticky info panel) // Center the playhead in the visible grid area (excluding the sticky info panel)
const infoWidth = 200; const infoWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width')
) || 200;
const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2; const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2;
const clampedScrollLeft = Math.max( const clampedScrollLeft = Math.max(
0, 0,
@@ -148,6 +152,36 @@ const MainContent: React.FC<MainContentProps> = ({
container.scrollLeft = clampedScrollLeft; container.scrollLeft = clampedScrollLeft;
}, [playheadPosition, isPlaying, autoScrollEnabled, timeSignature]); }, [playheadPosition, isPlaying, autoScrollEnabled, timeSignature]);
// Handle scroll requests from piano roll header clicks
useEffect(() => {
if (mainContentScrollRequest === null) return;
const container = mainContentRef.current;
if (!container) return;
const beatsPerBar = timeSignature.numerator;
const barPosition = mainContentScrollRequest / beatsPerBar;
const barWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
) || 40;
const playheadPixel = barPosition * barWidth;
// Center the playhead in the visible grid area (excluding the sticky info panel)
const infoWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-info-panel-width')
) || 200;
const targetScrollLeft = playheadPixel - (container.clientWidth - infoWidth) / 2;
const clampedScrollLeft = Math.max(
0,
Math.min(targetScrollLeft, container.scrollWidth - container.clientWidth)
);
container.scrollLeft = clampedScrollLeft;
// Clear the request after handling
useProjectStore.setState({ mainContentScrollRequest: null });
}, [mainContentScrollRequest, timeSignature]);
// Effect to verify track updates // Effect to verify track updates
useEffect(() => { useEffect(() => {
// Check for pending updates // Check for pending updates
@@ -774,6 +808,7 @@ const MainContent: React.FC<MainContentProps> = ({
const clickPosition = calculatePlayheadFromMouse(e.clientX); const clickPosition = calculatePlayheadFromMouse(e.clientX);
if (clickPosition !== null) { if (clickPosition !== null) {
setPlayheadPosition(clickPosition); setPlayheadPosition(clickPosition);
requestPianoRollScroll(clickPosition);
if (DEBUG_MODE.MAIN_CONTENT) { if (DEBUG_MODE.MAIN_CONTENT) {
console.log(`Single click on bar numbers - Set playhead to: ${clickPosition}`); console.log(`Single click on bar numbers - Set playhead to: ${clickPosition}`);
@@ -799,7 +834,7 @@ const MainContent: React.FC<MainContentProps> = ({
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
}; };
}, [calculateBarIndexFromMouse, calculatePlayheadFromMouse, setPlayheadPosition]); }, [calculateBarIndexFromMouse, calculatePlayheadFromMouse, setPlayheadPosition, requestPianoRollScroll]);
const { showInstrumentSelection, isLooping, loopingRange } = useProjectStore(); const { showInstrumentSelection, isLooping, loopingRange } = useProjectStore();
@@ -14,7 +14,7 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
timeSignature = { numerator: 4, denominator: 4 } // Default to 4/4 if not provided timeSignature = { numerator: 4, denominator: 4 } // Default to 4/4 if not provided
}) => { }) => {
// Get store access for playhead position updates // Get store access for playhead position updates
const { setPlayheadPosition } = useProjectStore(); const { setPlayheadPosition, requestMainContentScroll } = useProjectStore();
// Refs for drag functionality // Refs for drag functionality
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
@@ -122,8 +122,9 @@ const PianoGridHeader: React.FC<PianoGridHeaderProps> = ({
console.log(`Current bar: ${currentBarNumber} (beat ${currentPlayheadPosition})`); console.log(`Current bar: ${currentBarNumber} (beat ${currentPlayheadPosition})`);
console.log(`Destination bar: ${destinationBarNumber} (beat ${newPosition})`); console.log(`Destination bar: ${destinationBarNumber} (beat ${newPosition})`);
} }
setPlayheadPosition(newPosition); setPlayheadPosition(newPosition);
requestMainContentScroll(newPosition);
} }
}; };
+29 -1
View File
@@ -41,7 +41,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}) => { }) => {
const isSpectrogram = mode === 'spectrogram'; const isSpectrogram = mode === 'spectrogram';
const isHybrid = mode === 'hybrid'; const isHybrid = mode === 'hybrid';
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm } = useProjectStore(); const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest } = useProjectStore();
// Tool state for piano roll // Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
@@ -689,6 +689,34 @@ const PianoRoll: React.FC<PianoRollProps> = ({
container.scrollLeft = clampedScrollLeft; container.scrollLeft = clampedScrollLeft;
}, [playheadPosition, isPlaying, autoScrollEnabled]); }, [playheadPosition, isPlaying, autoScrollEnabled]);
// Handle scroll requests from main content bar numbers clicks
useEffect(() => {
if (pianoRollScrollRequest === null) return;
const container = pianoRollContentRef.current;
if (!container) return;
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
const playheadPixel = pianoRollScrollRequest * 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)
);
container.scrollLeft = clampedScrollLeft;
// Clear the request after handling
useProjectStore.setState({ pianoRollScrollRequest: null });
}, [pianoRollScrollRequest]);
// Update --region-grid-beat-width when zoom changes; reset on unmount // Update --region-grid-beat-width when zoom changes; reset on unmount
useEffect(() => { useEffect(() => {
document.documentElement.style.setProperty('--region-grid-beat-width', `${40 * pianoRollZoom}px`); document.documentElement.style.setProperty('--region-grid-beat-width', `${40 * pianoRollZoom}px`);
+1 -1
View File
@@ -49,7 +49,7 @@
} }
.track-info { .track-info {
width: 200px; width: var(--track-info-panel-width);
height: 120px; height: 120px;
padding: 15px 5px 15px 15px; padding: 15px 5px 15px 15px;
background-color: #2d2d2d; background-color: #2d2d2d;
+18
View File
@@ -109,6 +109,12 @@ interface ProjectState {
canRedo: boolean; canRedo: boolean;
undoDescription: string | null; undoDescription: string | null;
redoDescription: string | null; redoDescription: string | null;
// Cross-component scroll request state
requestMainContentScroll: (beatPosition: number) => void;
requestPianoRollScroll: (beatPosition: number) => void;
mainContentScrollRequest: number | null;
pianoRollScrollRequest: number | null;
// Actions // Actions
setProjectName: (name: string) => void; setProjectName: (name: string) => void;
@@ -340,6 +346,10 @@ export const useProjectStore = create<ProjectState>((set, get) => {
recordingNotes: [], recordingNotes: [],
recordingOriginalPlayhead: 0, recordingOriginalPlayhead: 0,
// Initial cross-component scroll request state
mainContentScrollRequest: null,
pianoRollScrollRequest: null,
// Actions // Actions
setProjectName: (name: string) => { setProjectName: (name: string) => {
try { try {
@@ -793,6 +803,14 @@ export const useProjectStore = create<ProjectState>((set, get) => {
set({ autoScrollEnabled: enabled }); set({ autoScrollEnabled: enabled });
}, },
requestMainContentScroll: (beatPosition: number) => {
set({ mainContentScrollRequest: beatPosition });
},
requestPianoRollScroll: (beatPosition: number) => {
set({ pianoRollScrollRequest: beatPosition });
},
startPlaying: async () => { startPlaying: async () => {
await KGCore.instance().startPlaying(); await KGCore.instance().startPlaying();
set({ isPlaying: true, autoScrollEnabled: true }); set({ isPlaying: true, autoScrollEnabled: true });
+1
View File
@@ -2,6 +2,7 @@
--time-signature-numerator: 4; --time-signature-numerator: 4;
--max-number-of-bars: 32; --max-number-of-bars: 32;
--track-grid-bar-width: 40px; --track-grid-bar-width: 40px;
--track-info-panel-width: 200px;
--region-piano-key-width: 60px; --region-piano-key-width: 60px;
--region-piano-key-height: 20px; --region-piano-key-height: 20px;
--region-grid-beat-width: 40px; --region-grid-beat-width: 40px;