feat: refactor the add track behavior to use a pop-up instead
This commit is contained in:
@@ -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 */
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user