fix: few minor UI adjustments and fixes

This commit is contained in:
Xiaohan-Tian
2026-05-07 23:28:16 -07:00
parent 917a1b0a21
commit 1e33caafc5
8 changed files with 199 additions and 34 deletions
+11 -4
View File
@@ -106,8 +106,13 @@
width: 22px; width: 22px;
height: 22px; height: 22px;
border: 1px solid #444; border: 1px solid #444;
border-radius: 3px; border-right: 0;
background-color: #3a3a3a; border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
background-color: #2f2f2f;
box-sizing: border-box;
color: #e0e0e0; color: #e0e0e0;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -120,8 +125,8 @@
} }
.list-event-add-button:hover { .list-event-add-button:hover {
background-color: #464646; background-color: #3b3b3b;
border-color: #5a5a5a; border-right: 0;
} }
.list-event-dropdown-button, .list-event-dropdown-button,
@@ -142,6 +147,8 @@
.list-event-type-button { .list-event-type-button {
min-width: 88px; min-width: 88px;
margin-left: 0; margin-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
} }
.list-event-delete-button { .list-event-delete-button {
+37 -7
View File
@@ -219,6 +219,21 @@
font-weight: 700; font-weight: 700;
} }
.piano-roll-toolbar .automation-toggle-button.active {
border-color: #e0e0e0;
border-right: 0;
}
.piano-roll-toolbar .automation-toggle-button.active:hover {
border-color: #f0f0f0;
border-right: 0;
}
.piano-roll-toolbar .automation-toggle-button.active:active {
border-color: #d6d6d6;
border-right: 0;
}
.piano-roll-toolbar .automation-type-dropdown { .piano-roll-toolbar .automation-type-dropdown {
min-width: 92px; min-width: 92px;
height: 20px; height: 20px;
@@ -318,6 +333,26 @@
overflow-x: visible; overflow-x: visible;
} }
.piano-roll-body-shell {
display: flex;
min-height: 0;
}
.piano-roll-keys-viewport {
position: sticky;
left: 0;
z-index: 15;
width: var(--region-piano-key-width);
flex: 0 0 var(--region-piano-key-width);
overflow: hidden;
border-right: 1px solid #3a3a3a;
background-color: #252525;
}
.piano-roll-keys-offset {
will-change: transform;
}
.piano-roll-automation-section { .piano-roll-automation-section {
flex: 0 0 50%; flex: 0 0 50%;
min-height: 0; min-height: 0;
@@ -374,24 +409,19 @@
.piano-roll-body { .piano-roll-body {
display: flex; display: flex;
min-height: calc(8 * 12 * var(--region-piano-key-height)); min-height: calc(8 * 12 * var(--region-piano-key-height));
/* 8 octaves * 12 notes * piano key height */ width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width));
width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width));
/* 32 bars * 160px width + piano keys width */
} }
.piano-keys-container { .piano-keys-container {
width: var(--region-piano-key-width); width: var(--region-piano-key-width);
flex-shrink: 0; flex-shrink: 0;
overflow: hidden; overflow: hidden;
border-right: 1px solid #3a3a3a;
background-color: #252525; background-color: #252525;
position: sticky;
left: 0;
z-index: 10;
} }
.piano-grid-container { .piano-grid-container {
flex: 1; flex: 1;
width: 100%;
min-width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width)); min-width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width));
min-height: calc(8 * 12 * var(--region-piano-key-height)); min-height: calc(8 * 12 * var(--region-piano-key-height));
z-index: 5; z-index: 5;
+11 -2
View File
@@ -205,6 +205,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Sync tool state // Sync tool state
const currentTool = pianoRollState.getActiveTool() as 'pointer' | 'pencil'; const currentTool = pianoRollState.getActiveTool() as 'pointer' | 'pencil';
setActiveTool(currentTool); setActiveTool(currentTool);
setAutomationEnabled(pianoRollState.getAutomationViewEnabled());
setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType);
if (DEBUG_MODE.PIANO_ROLL) { if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`); console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`);
@@ -691,11 +693,16 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}, [pianoRollZoom]); }, [pianoRollZoom]);
const handleAutomationToggle = useCallback(() => { const handleAutomationToggle = useCallback(() => {
setAutomationEnabled(current => !current); setAutomationEnabled(current => {
const next = !current;
KGPianoRollState.instance().setAutomationViewEnabled(next);
return next;
});
}, []); }, []);
const handleAutomationTypeChange = useCallback((value: PianoRollAutomationType) => { const handleAutomationTypeChange = useCallback((value: PianoRollAutomationType) => {
setAutomationType(value); setAutomationType(value);
KGPianoRollState.instance().setCurrentAutomationType(value);
}, []); }, []);
// Calculate C4 position and scroll to it when piano roll opens // Calculate C4 position and scroll to it when piano roll opens
@@ -745,7 +752,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}; };
container.addEventListener('scroll', handleScroll); container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll); return () => {
container.removeEventListener('scroll', handleScroll);
};
}, []); }, []);
// Auto-scroll to keep playhead centered during playback // Auto-scroll to keep playhead centered during playback
@@ -74,4 +74,30 @@ describe('PianoRollAutomationLane', () => {
expect(screen.getByText('No CC64 events in this region')).toBeInTheDocument(); expect(screen.getByText('No CC64 events in this region')).toBeInTheDocument();
}); });
it('renders step-style hold segments for non-interpolatable automation', () => {
const controllerEventsByType: KGMidiControllerEvent[][] = Array.from({ length: 128 }, () => []);
controllerEventsByType[64] = [
createMockMidiControllerEvent({ id: 'cc64-1', beat: 0.5, value: 127 }),
createMockMidiControllerEvent({ id: 'cc64-2', beat: 2, value: 0 }),
];
const region = createMockMidiRegion({
startFromBeat: 0,
controllerEventsByType,
});
const { container } = render(
<PianoRollAutomationLane
activeRegion={region}
automationType="cc-64"
maxBars={8}
timeSignature={{ numerator: 4, denominator: 4 }}
/>
);
expect(container.querySelector('.piano-roll-automation-line')).not.toBeNull();
expect(container.querySelector('polyline.piano-roll-automation-line')).toBeNull();
expect(container.querySelectorAll('line.piano-roll-automation-line')).toHaveLength(2);
});
}); });
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { import {
MIDI_PITCH_BEND_MAX, MIDI_PITCH_BEND_MAX,
@@ -6,6 +6,7 @@ import {
midiPitchBendToSignedValue, midiPitchBendToSignedValue,
} from '../../util/midiUtil'; } from '../../util/midiUtil';
import { import {
getAutomationInterpolationMode,
getControllerNumberForAutomationType, getControllerNumberForAutomationType,
PIANO_ROLL_AUTOMATION_OPTIONS, PIANO_ROLL_AUTOMATION_OPTIONS,
type PianoRollAutomationType, type PianoRollAutomationType,
@@ -28,8 +29,8 @@ interface PianoRollAutomationLaneProps {
} }
const AUTOMATION_COLOR = '#87CEFA'; const AUTOMATION_COLOR = '#87CEFA';
const LANE_HEIGHT = 160;
const LANE_PADDING_Y = 16; const LANE_PADDING_Y = 16;
const MIN_LANE_HEIGHT = 160;
const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
activeRegion, activeRegion,
@@ -38,8 +39,33 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
timeSignature, timeSignature,
redrawVersion = 0, redrawVersion = 0,
}) => { }) => {
const laneRef = useRef<HTMLDivElement | null>(null);
const [laneHeight, setLaneHeight] = useState(MIN_LANE_HEIGHT);
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40; const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
const keyWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')) || 60; const keyWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')) || 60;
useEffect(() => {
const element = laneRef.current;
if (!element) {
return;
}
const updateHeight = () => {
setLaneHeight(Math.max(Math.round(element.clientHeight), MIN_LANE_HEIGHT));
};
updateHeight();
const observer = new ResizeObserver(() => {
updateHeight();
});
observer.observe(element);
return () => {
observer.disconnect();
};
}, []);
const points = useMemo<AutomationPoint[]>(() => { const points = useMemo<AutomationPoint[]>(() => {
if (!activeRegion) { if (!activeRegion) {
return []; return [];
@@ -70,15 +96,16 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
const selectedOption = PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === automationType); const selectedOption = PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === automationType);
const laneLabel = selectedOption?.label ?? automationType; const laneLabel = selectedOption?.label ?? automationType;
const interpolationMode = getAutomationInterpolationMode(automationType);
const totalBeats = maxBars * timeSignature.numerator; const totalBeats = maxBars * timeSignature.numerator;
const totalWidth = 'calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width))'; const totalWidth = 'calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width))';
const toY = (value: number): number => { const toY = (value: number): number => {
const minValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MIN : 0; const minValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MIN : 0;
const maxValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MAX : 127; const maxValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MAX : 127;
const usableHeight = LANE_HEIGHT - LANE_PADDING_Y * 2; const usableHeight = laneHeight - LANE_PADDING_Y * 2;
const normalized = (value - minValue) / (maxValue - minValue); const normalized = (value - minValue) / (maxValue - minValue);
return LANE_HEIGHT - LANE_PADDING_Y - normalized * usableHeight; return laneHeight - LANE_PADDING_Y - normalized * usableHeight;
}; };
const svgPoints = points.map(point => { const svgPoints = points.map(point => {
@@ -90,6 +117,10 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
}); });
const polylinePoints = (() => { const polylinePoints = (() => {
if (interpolationMode === 'step') {
return '';
}
if (svgPoints.length === 0) { if (svgPoints.length === 0) {
return ''; return '';
} }
@@ -107,11 +138,25 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
.join(' '); .join(' ');
})(); })();
const stepSegments = (() => {
if (interpolationMode !== 'step' || svgPoints.length === 0) {
return [];
}
return svgPoints.map((point, index) => ({
id: `${point.id}-step`,
x1: point.x,
y1: point.y,
x2: index < svgPoints.length - 1 ? svgPoints[index + 1].x : beatWidth * totalBeats + keyWidth,
}));
})();
return ( return (
<div <div
className="piano-roll-automation-lane" className="piano-roll-automation-lane"
data-testid="piano-roll-automation-lane" data-testid="piano-roll-automation-lane"
aria-label={`${laneLabel} automation lane`} aria-label={`${laneLabel} automation lane`}
ref={laneRef}
> >
<div className="piano-roll-automation-track" style={{ width: totalWidth }}> <div className="piano-roll-automation-track" style={{ width: totalWidth }}>
<div className="piano-roll-automation-gutter" /> <div className="piano-roll-automation-gutter" />
@@ -120,11 +165,11 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
<svg <svg
className="piano-roll-automation-svg" className="piano-roll-automation-svg"
width="100%" width="100%"
height={LANE_HEIGHT} height={laneHeight}
viewBox={`0 0 ${beatWidth * totalBeats + keyWidth} ${LANE_HEIGHT}`} viewBox={`0 0 ${beatWidth * totalBeats + keyWidth} ${laneHeight}`}
preserveAspectRatio="none" preserveAspectRatio="none"
> >
{points.length > 0 && ( {interpolationMode === 'linear' && points.length > 0 && (
<polyline <polyline
className="piano-roll-automation-line" className="piano-roll-automation-line"
fill="none" fill="none"
@@ -133,8 +178,20 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
points={polylinePoints} points={polylinePoints}
/> />
)} )}
{interpolationMode === 'step' && stepSegments.map(segment => (
<line
key={segment.id}
className="piano-roll-automation-line"
x1={segment.x1}
y1={segment.y1}
x2={segment.x2}
y2={segment.y1}
stroke={AUTOMATION_COLOR}
strokeWidth="2"
/>
))}
{svgPoints.map(point => { {svgPoints.map(point => {
const labelY = Math.max(14, Math.min(LANE_HEIGHT - 6, point.y - 10)); const labelY = Math.max(14, Math.min(laneHeight - 6, point.y - 10));
return ( return (
<g key={point.id}> <g key={point.id}>
+17 -4
View File
@@ -76,6 +76,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
const isSpectrogram = mode === 'spectrogram'; const isSpectrogram = mode === 'spectrogram';
const showAutomationLane = automationEnabled && !isSpectrogram; const showAutomationLane = automationEnabled && !isSpectrogram;
const [spectrogramLoading, setSpectrogramLoading] = useState(false); const [spectrogramLoading, setSpectrogramLoading] = useState(false);
const [noteScrollTop, setNoteScrollTop] = useState(0);
const handleSpectrogramLoadingChange = useCallback((loading: boolean) => { const handleSpectrogramLoadingChange = useCallback((loading: boolean) => {
setSpectrogramLoading(loading); setSpectrogramLoading(loading);
}, []); }, []);
@@ -291,10 +292,21 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
<div className={`piano-roll-main-section ${showAutomationLane ? 'with-automation' : ''}`}> <div className={`piano-roll-main-section ${showAutomationLane ? 'with-automation' : ''}`}>
<PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} /> <PianoGridHeader maxBars={maxBars} timeSignature={timeSignature} />
<div className="piano-roll-note-scroll" ref={noteScrollRef}> <div className="piano-roll-body-shell">
<div className="piano-roll-body"> <div className="piano-roll-keys-viewport">
<PianoKeys activeRegion={activeRegion} /> <div
className="piano-roll-keys-offset"
style={{ transform: `translateY(-${noteScrollTop}px)` }}
>
<PianoKeys activeRegion={activeRegion} />
</div>
</div>
<div
className="piano-roll-note-scroll"
ref={noteScrollRef}
onScroll={(event) => setNoteScrollTop(event.currentTarget.scrollTop)}
>
<div className="piano-roll-body">
<PianoGrid <PianoGrid
gridRef={pianoGridRef} gridRef={pianoGridRef}
onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick} onDoubleClick={isSpectrogram ? () => {} : handleGridDoubleClick}
@@ -319,6 +331,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
{memoizedNotes} {memoizedNotes}
{!isSpectrogram && recordingNoteOverlays} {!isSpectrogram && recordingNoteOverlays}
</PianoGrid> </PianoGrid>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -9,15 +9,16 @@ export type PianoRollAutomationType =
export interface PianoRollAutomationOption { export interface PianoRollAutomationOption {
label: string; label: string;
value: PianoRollAutomationType; value: PianoRollAutomationType;
interpolationMode: 'linear' | 'step';
} }
export const PIANO_ROLL_AUTOMATION_OPTIONS: PianoRollAutomationOption[] = [ export const PIANO_ROLL_AUTOMATION_OPTIONS: PianoRollAutomationOption[] = [
{ label: 'Pitch Bend', value: 'pitch-bend' }, { label: 'Pitch Bend', value: 'pitch-bend', interpolationMode: 'linear' },
{ label: 'CC1', value: 'cc-1' }, { label: 'CC1', value: 'cc-1', interpolationMode: 'linear' },
{ label: 'CC2', value: 'cc-2' }, { label: 'CC2', value: 'cc-2', interpolationMode: 'linear' },
{ label: 'CC7', value: 'cc-7' }, { label: 'CC7', value: 'cc-7', interpolationMode: 'linear' },
{ label: 'CC11', value: 'cc-11' }, { label: 'CC11', value: 'cc-11', interpolationMode: 'linear' },
{ label: 'CC64', value: 'cc-64' }, { label: 'CC64', value: 'cc-64', interpolationMode: 'step' },
]; ];
export function getControllerNumberForAutomationType(type: PianoRollAutomationType): number | null { export function getControllerNumberForAutomationType(type: PianoRollAutomationType): number | null {
@@ -37,3 +38,7 @@ export function getControllerNumberForAutomationType(type: PianoRollAutomationTy
return null; return null;
} }
} }
export function getAutomationInterpolationMode(type: PianoRollAutomationType): 'linear' | 'step' {
return PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === type)?.interpolationMode ?? 'linear';
}
+18
View File
@@ -13,6 +13,8 @@ export class KGPianoRollState {
private currentSnap: string = "NO SNAP"; private currentSnap: string = "NO SNAP";
private lastEditedNoteLength: number = 1; // Default to 1 beat private lastEditedNoteLength: number = 1; // Default to 1 beat
private currentMode: string = "ionian"; // Default mode private currentMode: string = "ionian"; // Default mode
private automationViewEnabled: boolean = false;
private currentAutomationType: string = "pitch-bend";
// Chord guide state // Chord guide state
private currentSuitableChords: Record<string, string[]> = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]}) private currentSuitableChords: Record<string, string[]> = {}; // Map of chord symbols to note names (e.g., {"I": ["C", "E", "G"]})
@@ -65,6 +67,22 @@ export class KGPianoRollState {
this.currentMode = mode; this.currentMode = mode;
} }
public getAutomationViewEnabled(): boolean {
return this.automationViewEnabled;
}
public setAutomationViewEnabled(enabled: boolean): void {
this.automationViewEnabled = enabled;
}
public getCurrentAutomationType(): string {
return this.currentAutomationType;
}
public setCurrentAutomationType(type: string): void {
this.currentAutomationType = type;
}
public getCurrentSuitableChords(): Record<string, string[]> { public getCurrentSuitableChords(): Record<string, string[]> {
return this.currentSuitableChords; return this.currentSuitableChords;
} }