feat: refactor the add track behavior to use a pop-up instead

This commit is contained in:
Xiaohan-Tian
2026-05-23 11:09:02 -07:00
parent a7071f318c
commit 041641c101
6 changed files with 380 additions and 16 deletions
+81 -10
View File
@@ -3,14 +3,16 @@
display: flex;
flex-direction: column;
flex: 1;
overflow: auto; /* Main scrollable container */
overflow: auto;
/* Main scrollable container */
position: relative;
}
.main-content-wrapper {
display: flex;
flex-direction: column;
min-width: calc(var(--track-info-panel-width) + 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;
position: relative;
}
@@ -24,12 +26,75 @@
background-color: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
border-right: 1px solid #3a3a3a;
z-index: 1002; /* Higher than other elements to ensure it's always visible */
z-index: 1002;
/* Higher than other elements to ensure it's always visible */
display: flex;
flex-direction: row;
align-items: stretch;
}
.track-header-controls {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 12px;
box-sizing: border-box;
}
.track-header-button {
--toolbar-button-bg: transparent;
--toolbar-button-fg: #e0e0e0;
width: 28px;
height: 28px;
border: none;
border-radius: 3px;
background-color: var(--toolbar-button-bg);
color: var(--toolbar-button-fg);
display: inline-flex;
align-items: center;
font-size: 12px;
justify-content: center;
cursor: pointer;
transition:
background-color 0.18s ease,
color 0.18s ease,
transform 0.12s ease,
box-shadow 0.18s ease;
}
.track-header-button svg {
font-size: 14px;
}
.track-header-button:hover {
--toolbar-button-bg: #3a3a3a;
}
.track-header-button.active {
--toolbar-button-bg: #e0e0e0;
--toolbar-button-fg: #2d2d2d;
}
.track-header-button.active:hover {
--toolbar-button-bg: #f0f0f0;
}
.track-header-button:active {
--toolbar-button-bg: #5a5a5a;
transform: scale(0.95);
}
.track-header-button.active:active {
--toolbar-button-bg: #d6d6d6;
}
.track-header-button:focus,
.track-header-button:focus-visible {
outline: none;
box-shadow: none;
}
/* Offset spacer when instrument selection panel is visible on the left */
.main-content.has-left-instrument .top-left-spacer {
left: 300px;
@@ -43,9 +108,12 @@
border-bottom: 1px solid #3a3a3a;
background-color: #2d2d2d;
z-index: 20;
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 */
cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */
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 */
cursor: pointer;
/* Show pointer cursor on hover to indicate interactivity */
}
.bar-numbers:hover {
@@ -135,8 +203,11 @@
}
.grid-container {
margin-left: 0; /* No need for margin since we're using flex */
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Ensure minimum width */
margin-left: 0;
/* No need for margin since we're using flex */
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width));
/* Ensure minimum width */
min-height: fit-content;
position: relative; /* Required for absolute positioned playhead */
}
position: relative;
/* Required for absolute positioned playhead */
}
+74 -3
View File
@@ -69,6 +69,13 @@ type StoreSelector = (...args: [typeof storeState]) => unknown;
// eslint-disable-next-line no-unused-vars
type RegionClickHandler = (...args: [string, { shiftKey: boolean }]) => void;
const finishTrackCreateDialogClose = () => {
const overlay = document.querySelector('.dialog-overlay');
if (overlay) {
fireEvent.animationEnd(overlay);
}
};
vi.mock('../stores/projectStore', () => ({
useProjectStore: (selector?: StoreSelector) => (
selector ? selector(storeState) : storeState
@@ -138,6 +145,8 @@ describe('MainContent', () => {
storeState.setActiveRegionId.mockClear();
storeState.openMidiPianoRoll.mockClear();
storeState.openSpectrogramViewer.mockClear();
storeState.addTrack.mockClear();
storeState.addAudioTrack.mockClear();
});
it('updates activeRegionId when selecting a region with piano roll closed', () => {
@@ -205,11 +214,11 @@ describe('MainContent', () => {
expect(storeState.setActiveRegionId).toHaveBeenCalledWith(null);
});
it('renders the two-row timeline ruler with add-track buttons and beat markers', () => {
it('renders the two-row timeline ruler with track controls and beat markers', () => {
const { container } = render(<MainContent />);
expect(screen.getByRole('button', { name: '+ MIDI' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '+ Audio' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create track' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Show global tracks' })).toBeInTheDocument();
const barCells = container.querySelectorAll('[data-testid="bar-number-cell"]');
expect(barCells).toHaveLength(storeState.maxBars);
@@ -230,4 +239,66 @@ describe('MainContent', () => {
expect(firstBeatRow?.querySelectorAll('.beat-marker')).toHaveLength(2);
});
it('opens the track creation modal with MIDI selected by default', () => {
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Create track' }));
expect(screen.getByRole('dialog', { name: 'Create New Track' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'MIDI' }).className).toContain('selected');
expect(screen.getByRole('button', { name: 'Audio' }).className).not.toContain('selected');
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument();
});
it('creates an audio track when audio is selected in the modal', () => {
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Create track' }));
fireEvent.click(screen.getByRole('button', { name: 'Audio' }));
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
finishTrackCreateDialogClose();
expect(storeState.addAudioTrack).toHaveBeenCalledTimes(1);
expect(storeState.addTrack).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: 'Create New Track' })).not.toBeInTheDocument();
});
it('creates a MIDI track by default when confirming the modal', () => {
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Create track' }));
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
finishTrackCreateDialogClose();
expect(storeState.addTrack).toHaveBeenCalledTimes(1);
expect(storeState.addAudioTrack).not.toHaveBeenCalled();
});
it('closes the track creation modal without creating a track when canceled', () => {
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Create track' }));
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
finishTrackCreateDialogClose();
expect(storeState.addTrack).not.toHaveBeenCalled();
expect(storeState.addAudioTrack).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: 'Create New Track' })).not.toBeInTheDocument();
});
it('toggles the mock global tracks button active state locally', () => {
render(<MainContent />);
const globalTracksButton = screen.getByRole('button', { name: 'Show global tracks' });
expect(globalTracksButton.className).not.toContain('active');
fireEvent.click(globalTracksButton);
expect(globalTracksButton.className).toContain('active');
fireEvent.click(globalTracksButton);
expect(globalTracksButton.className).not.toContain('active');
});
});
+45 -2
View File
@@ -10,6 +10,7 @@ import { KGAudioRegion } from '../core/region/KGAudioRegion';
import TrackInfoPanel from './track/TrackInfoPanel';
import TrackGridPanel from './track/TrackGridPanel';
import PianoRoll from './piano-roll/PianoRoll';
import { TrackCreateDialog } from './common';
import type { RegionClickOptions, RegionUI } from './interfaces';
import { DEBUG_MODE, BAR_NUMBERS_CONSTANTS, TOOLBAR_CONSTANTS } from '../constants';
import { useRegionOperations } from '../hooks/useRegionOperations';
@@ -17,6 +18,8 @@ import { regionDeleteManager } from '../util/regionDeleteUtil';
import { KGMainContentState } from '../core/state/KGMainContentState';
import { ChangeLoopSettingsCommand } from '../core/commands';
import { DeleteTrackAutomationPointsCommand } from '../core/commands';
import { FaPlus } from 'react-icons/fa';
import { FaSquareArrowUpRight } from 'react-icons/fa6';
interface MainContentProps {
onTrackClick?: () => void;
@@ -76,6 +79,8 @@ const MainContent: React.FC<MainContentProps> = ({
// Region selection state
const [selectedRegionId, setSelectedRegionId] = useState<string | null>(null);
const [showCreateTrackModal, setShowCreateTrackModal] = useState(false);
const [showGlobalTracksMock, setShowGlobalTracksMock] = useState(false);
// Use the region operations hook
const { deleteSelectedRegions } = useRegionOperations({
@@ -763,6 +768,11 @@ const MainContent: React.FC<MainContentProps> = ({
setActiveRegionId(null);
};
const openCreateTrackModal = useCallback((e?: React.MouseEvent<HTMLButtonElement>) => {
e?.stopPropagation();
setShowCreateTrackModal(true);
}, []);
/**
* Add keyboard event listener for region deletion
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
@@ -1011,8 +1021,29 @@ const MainContent: React.FC<MainContentProps> = ({
<div className="main-content-wrapper" onClick={handleEmptyMainContentClick}>
{/* Top-left spacer */}
<div className="top-left-spacer">
<button className="add-track-btn" onClick={() => addTrack()}>+ MIDI</button>
<button className="add-track-btn" onClick={() => addAudioTrack()}>+ Audio</button>
<div className="track-header-controls">
<button
type="button"
className="track-header-button"
aria-label="Create track"
title="Create track"
onClick={openCreateTrackModal}
>
<FaPlus />
</button>
<button
type="button"
className={`track-header-button${showGlobalTracksMock ? ' active' : ''}`}
aria-label="Show global tracks"
title="Show global tracks"
onClick={(e) => {
e.stopPropagation();
setShowGlobalTracksMock(prev => !prev);
}}
>
<FaSquareArrowUpRight />
</button>
</div>
</div>
{/* Bar numbers at the top */}
@@ -1077,6 +1108,18 @@ const MainContent: React.FC<MainContentProps> = ({
</div>
{/* Piano Roll / Spectrogram Viewer - render using portal */}
{showCreateTrackModal && (
<TrackCreateDialog
onResolve={(result) => {
setShowCreateTrackModal(false);
if (result === 'audio') {
addAudioTrack();
} else if (result === 'midi') {
addTrack();
}
}}
/>
)}
{showPianoRoll && createPortal(
<PianoRoll
onClose={handlePianoRollClose}
@@ -0,0 +1,62 @@
.track-create-dialog-modal {
max-width: 520px;
}
.track-create-dialog-body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.track-create-option {
border: 1px solid #4a4a4a;
border-radius: 12px;
background: linear-gradient(180deg, #323232 0%, #282828 100%);
color: #e0e0e0;
padding: 28px 18px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
cursor: pointer;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.12s ease;
}
.track-create-option:hover {
border-color: #6a6a6a;
transform: translateY(-1px);
}
.track-create-option.selected {
border-color: #e1ae01;
box-shadow: inset 0 0 0 1px rgba(225, 174, 1, 0.35);
}
.track-create-option-icon {
width: 72px;
height: 72px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 30px;
color: #ffffff;
}
.track-create-option-icon.midi {
background-color: #159947;
}
.track-create-option-icon.audio {
background-color: #1f78d1;
}
.track-create-option-label {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
+116
View File
@@ -0,0 +1,116 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FaMusic, FaTimes } from 'react-icons/fa';
import { FaWaveSquare } from 'react-icons/fa6';
import './DialogProvider.css';
import './TrackCreateDialog.css';
type TrackCreateDialogResult = 'cancel' | 'midi' | 'audio';
interface TrackCreateDialogProps {
onResolve: (result: TrackCreateDialogResult) => void;
}
const TrackCreateDialog: React.FC<TrackCreateDialogProps> = ({ onResolve }) => {
const [selectedTrackType, setSelectedTrackType] = useState<'midi' | 'audio'>('midi');
const [isClosing, setIsClosing] = useState(false);
const pendingResultRef = useRef<TrackCreateDialogResult>('cancel');
const mouseDownOnOverlay = useRef(false);
const close = useCallback((result: TrackCreateDialogResult) => {
pendingResultRef.current = result;
setIsClosing(true);
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close('cancel');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [close]);
const handleAnimationEnd = useCallback((e: React.AnimationEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (!isClosing) return;
onResolve(pendingResultRef.current);
}, [isClosing, onResolve]);
return createPortal(
<div
className={`dialog-overlay${isClosing ? ' dialog-overlay-closing' : ''}`}
onMouseDown={(e) => {
mouseDownOnOverlay.current = e.target === e.currentTarget;
}}
onClick={(e) => {
if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
close('cancel');
}
}}
onAnimationEnd={handleAnimationEnd}
>
<div
className={`dialog-modal track-create-dialog-modal${isClosing ? ' dialog-modal-closing' : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby="track-create-title"
onClick={(e) => e.stopPropagation()}
>
<div className="dialog-header">
<h3 id="track-create-title" className="dialog-title">Create New Track</h3>
<button
className="dialog-close-btn"
onClick={() => close('cancel')}
aria-label="Close dialog"
>
<FaTimes />
</button>
</div>
<div className="dialog-body track-create-dialog-body">
<button
type="button"
className={`track-create-option${selectedTrackType === 'midi' ? ' selected' : ''}`}
onClick={() => setSelectedTrackType('midi')}
>
<div className="track-create-option-icon midi">
<FaMusic />
</div>
<span className="track-create-option-label">MIDI</span>
</button>
<button
type="button"
className={`track-create-option${selectedTrackType === 'audio' ? ' selected' : ''}`}
onClick={() => setSelectedTrackType('audio')}
>
<div className="track-create-option-icon audio">
<FaWaveSquare />
</div>
<span className="track-create-option-label">Audio</span>
</button>
</div>
<div className="dialog-footer">
<button
type="button"
className="dialog-btn dialog-btn-cancel"
onClick={() => close('cancel')}
>
Cancel
</button>
<button
type="button"
className="dialog-btn dialog-btn-primary"
onClick={() => close(selectedTrackType)}
>
Create
</button>
</div>
</div>
</div>,
document.body
);
};
export default TrackCreateDialog;
+2 -1
View File
@@ -4,5 +4,6 @@ export { default as FileImportModal } from './FileImportModal';
export { default as LoadingOverlay } from './LoadingOverlay';
export { default as OpenProjectModal } from './OpenProjectModal';
export { default as DialogProvider } from './DialogProvider';
export { default as TrackCreateDialog } from './TrackCreateDialog';
export { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil';
export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';