feat: add cross-component playhead scroll synchronization
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user