diff --git a/src/components/MainContent.css b/src/components/MainContent.css index 5f35e2e..24e0047 100644 --- a/src/components/MainContent.css +++ b/src/components/MainContent.css @@ -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 */ +} \ No newline at end of file diff --git a/src/components/MainContent.test.tsx b/src/components/MainContent.test.tsx index fef3c13..f5151ff 100644 --- a/src/components/MainContent.test.tsx +++ b/src/components/MainContent.test.tsx @@ -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(); - 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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'); + }); }); diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index f2e385c..54942fa 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -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 = ({ // Region selection state const [selectedRegionId, setSelectedRegionId] = useState(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 = ({ setActiveRegionId(null); }; + const openCreateTrackModal = useCallback((e?: React.MouseEvent) => { + 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 = ({
{/* Top-left spacer */}
- - +
+ + +
{/* Bar numbers at the top */} @@ -1077,6 +1108,18 @@ const MainContent: React.FC = ({
{/* Piano Roll / Spectrogram Viewer - render using portal */} + {showCreateTrackModal && ( + { + setShowCreateTrackModal(false); + if (result === 'audio') { + addAudioTrack(); + } else if (result === 'midi') { + addTrack(); + } + }} + /> + )} {showPianoRoll && createPortal( void; +} + +const TrackCreateDialog: React.FC = ({ onResolve }) => { + const [selectedTrackType, setSelectedTrackType] = useState<'midi' | 'audio'>('midi'); + const [isClosing, setIsClosing] = useState(false); + const pendingResultRef = useRef('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) => { + if (e.target !== e.currentTarget) return; + if (!isClosing) return; + onResolve(pendingResultRef.current); + }, [isClosing, onResolve]); + + return createPortal( +
{ + mouseDownOnOverlay.current = e.target === e.currentTarget; + }} + onClick={(e) => { + if (e.target === e.currentTarget && mouseDownOnOverlay.current) { + close('cancel'); + } + }} + onAnimationEnd={handleAnimationEnd} + > +
e.stopPropagation()} + > +
+

Create New Track

+ +
+
+ + +
+
+ + +
+
+
, + document.body + ); +}; + +export default TrackCreateDialog; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 9fafde9..093aaf4 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -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'; \ No newline at end of file +export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';