diff --git a/src/components/MainContent.css b/src/components/MainContent.css index 46e964a..685b2d4 100644 --- a/src/components/MainContent.css +++ b/src/components/MainContent.css @@ -26,7 +26,7 @@ background-color: #2d2d2d; border-bottom: 1px solid #3a3a3a; border-right: 1px solid #3a3a3a; - z-index: 1002; + z-index: 1005; /* Higher than other elements to ensure it's always visible */ display: flex; flex-direction: row; @@ -107,7 +107,7 @@ display: flex; border-bottom: 1px solid #3a3a3a; background-color: #2d2d2d; - z-index: 20; + z-index: 1004; margin-left: var(--track-info-panel-width); /* Offset for info-container */ width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); diff --git a/src/components/piano-roll/PianoGridHeader.test.tsx b/src/components/piano-roll/PianoGridHeader.test.tsx new file mode 100644 index 0000000..35375e5 --- /dev/null +++ b/src/components/piano-roll/PianoGridHeader.test.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import PianoGridHeader from './PianoGridHeader'; + +const setPlayheadPosition = vi.fn(); +const requestMainContentScroll = vi.fn(); +const getPlayheadPosition = vi.fn(() => 0); + +const storeState = { + setPlayheadPosition, + requestMainContentScroll, +}; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: () => storeState, +})); + +vi.mock('../../core/KGCore', () => ({ + KGCore: { + instance: () => ({ + getPlayheadPosition, + }), + }, +})); + +function renderHeader({ + hasPianoKeys = true, + scrollLeft = 0, + paddingLeft = hasPianoKeys ? '60px' : '0px', +}: { + hasPianoKeys?: boolean; + scrollLeft?: number; + paddingLeft?: string; +} = {}) { + const view = render( +
+ +
+ ); + + const scrollContainer = view.container.querySelector('.piano-roll-note-scroll') as HTMLDivElement; + const header = view.container.querySelector('.piano-grid-header') as HTMLDivElement; + + Object.defineProperty(scrollContainer, 'scrollLeft', { + configurable: true, + value: scrollLeft, + writable: true, + }); + + header.style.paddingLeft = paddingLeft; + + Object.defineProperty(header, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + left: 100, + top: 0, + right: 600, + bottom: 20, + width: 500, + height: 20, + x: 100, + y: 0, + toJSON: () => ({}), + }), + }); + + return { ...view, header }; +} + +describe('PianoGridHeader', () => { + beforeEach(() => { + document.documentElement.style.setProperty('--region-grid-beat-width', '40px'); + setPlayheadPosition.mockClear(); + requestMainContentScroll.mockClear(); + getPlayheadPosition.mockClear(); + getPlayheadPosition.mockReturnValue(0); + }); + + it('seeks to the expected beat without horizontal scroll', () => { + const { header } = renderHeader(); + + fireEvent.click(header, { clientX: 280 }); + + expect(setPlayheadPosition).toHaveBeenCalledTimes(1); + expect(setPlayheadPosition).toHaveBeenCalledWith(3); + expect(requestMainContentScroll).toHaveBeenCalledTimes(1); + expect(requestMainContentScroll).toHaveBeenCalledWith(3); + }); + + it('includes the note scroll offset when seeking after horizontal scroll', () => { + const { header } = renderHeader({ scrollLeft: 160 }); + + fireEvent.click(header, { clientX: 280 }); + + expect(setPlayheadPosition).toHaveBeenCalledTimes(1); + expect(setPlayheadPosition).toHaveBeenCalledWith(7); + expect(requestMainContentScroll).toHaveBeenCalledTimes(1); + expect(requestMainContentScroll).toHaveBeenCalledWith(7); + }); + + it('does not subtract a gutter when piano keys are hidden', () => { + const { header } = renderHeader({ hasPianoKeys: false }); + + fireEvent.click(header, { clientX: 180 }); + + expect(setPlayheadPosition).toHaveBeenCalledTimes(1); + expect(setPlayheadPosition).toHaveBeenCalledWith(2); + expect(requestMainContentScroll).toHaveBeenCalledWith(2); + }); + + it('uses the same corrected math on mousedown for drag-to-seek', () => { + const { header } = renderHeader({ scrollLeft: 80 }); + + fireEvent.mouseDown(header, { button: 0, clientX: 280 }); + + expect(setPlayheadPosition).toHaveBeenCalledTimes(1); + expect(setPlayheadPosition).toHaveBeenCalledWith(5); + expect(requestMainContentScroll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/piano-roll/PianoGridHeader.tsx b/src/components/piano-roll/PianoGridHeader.tsx index 702a0ff..a8f9c4b 100644 --- a/src/components/piano-roll/PianoGridHeader.tsx +++ b/src/components/piano-roll/PianoGridHeader.tsx @@ -26,20 +26,16 @@ const PianoGridHeader: React.FC = ({ // Utility function to calculate playhead position from mouse coordinates const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => { if (!headerElementRef.current) return null; - - const rect = headerElementRef.current.getBoundingClientRect(); + + const headerElement = headerElementRef.current; + const rect = headerElement.getBoundingClientRect(); const relativeX = clientX - rect.left; - - // Account for the piano keys width offset - const pianoKeysWidth = hasPianoKeys - ? (parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width') - ) || 60) - : 0; - - const adjustedX = relativeX - pianoKeysWidth; - - // If the click is in the piano keys area (left side), ignore it + const scrollContainer = headerElement.closest('.piano-roll-note-scroll') as HTMLElement | null; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const leftGutter = parseFloat(getComputedStyle(headerElement).paddingLeft) || 0; + const adjustedX = relativeX + scrollLeft - leftGutter; + + // Ignore clicks inside the visual left gutter reserved for piano keys. if (adjustedX < 0) { return null; }