diff --git a/src/components/MainContent.css b/src/components/MainContent.css index b9d4937..46e964a 100644 --- a/src/components/MainContent.css +++ b/src/components/MainContent.css @@ -366,6 +366,50 @@ overflow: visible; } +.global-chord-drag-feedback { + position: fixed; + z-index: 10020; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: 999px; + color: #1f1f1f; + font-size: 11px; + font-weight: 600; + line-height: 1; + pointer-events: none; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.22); + backdrop-filter: blur(8px); +} + +.global-chord-drag-feedback-import { + background: rgba(196, 255, 207, 0.96); +} + +.global-chord-drag-feedback-move { + background: rgba(255, 241, 214, 0.96); +} + +.global-chord-drag-feedback-blocked { + background: rgba(255, 214, 214, 0.97); +} + +.global-chord-drag-feedback-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.75); + flex: 0 0 auto; +} + +.global-chord-drag-feedback-label { + white-space: nowrap; +} + .global-key-signature-region .floating-popup { min-width: 0; width: var(--track-grid-bar-width); diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 274eea7..09e544d 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -17,6 +17,15 @@ import { useMainContentRegions } from '../hooks/useMainContentRegions'; import { useMainContentGlobalTracks } from '../hooks/useMainContentGlobalTracks'; import { useMainContentViewport } from '../hooks/useMainContentViewport'; import MainContentGlobalTracksSection from './global-track/MainContentGlobalTracksSection'; +import { ImportChordRegionsCommand } from '../core/commands'; +import { TrackType } from '../core/track/KGTrack'; +import { KGChordRegion } from '../core/region/KGChordRegion'; +import { showAlert } from '../util/dialogUtil'; +import { + buildChordRegionImportPlan, + CHORD_REGION_IMPORT_REGION_NAME, + resolveChordRegionImportSelection, +} from '../util/chordRegionImportUtil'; interface MainContentProps { onTrackClick?: () => void; @@ -244,6 +253,73 @@ const MainContent: React.FC = ({ setShowGlobalTracksMock(previous => !previous); }, []); + const handleGlobalChordDropToTrack = useCallback(async (draggedRegionId: string, trackIndex: number) => { + const targetTrack = tracks[trackIndex]; + if (!targetTrack) { + await showAlert('Unable to find the destination track for this import.'); + return; + } + + if (targetTrack.getType() !== TrackType.MIDI) { + await showAlert('Chord regions can only be converted into MIDI tracks. Please drop them onto a MIDI track.'); + return; + } + + const chordRegionIdSet = new Set( + globalTracks.flatMap(track => track.getRegions()) + .filter((region): region is KGChordRegion => region instanceof KGChordRegion) + .map(region => region.getId()) + ); + const selectedChordRegionIds = resolveChordRegionImportSelection( + draggedRegionId, + selectedRegionIds.filter(regionId => chordRegionIdSet.has(regionId)), + ); + const chordRegions = selectedChordRegionIds.map(regionId => ( + globalTracks.flatMap(track => track.getRegions()).find(candidate => candidate.getId() === regionId) + )).filter((region): region is KGChordRegion => region instanceof KGChordRegion); + + if (chordRegions.length === 0) { + await showAlert('No chord regions were available to import. Please select a chord region and try again.'); + return; + } + + const planResult = buildChordRegionImportPlan(chordRegions); + if (!planResult.ok) { + await showAlert(planResult.error.message); + return; + } + + try { + const command = new ImportChordRegionsCommand( + targetTrack.getId().toString(), + trackIndex, + planResult.plan.startBeat, + planResult.plan.lengthInBeats, + planResult.plan.notes, + CHORD_REGION_IMPORT_REGION_NAME, + ); + KGCore.instance().executeCommand(command, { rethrow: true }); + + const createdRegion = command.getCreatedRegion(); + if (!createdRegion) { + refreshProjectState(); + return; + } + + mainContentRegions.handleExternalDropComplete(trackIndex, { + id: createdRegion.getId(), + trackId: targetTrack.getId().toString(), + trackIndex, + barNumber: (createdRegion.getStartFromBeat() / timeSignature.numerator) + 1, + length: createdRegion.getLength() / timeSignature.numerator, + name: createdRegion.getName(), + }); + } catch (error) { + console.error('[ChordImport] Gesture import failed:', error); + await showAlert('Unable to import the selected chord regions into a MIDI region. Please try again.'); + } + }, [globalTracks, mainContentRegions, refreshProjectState, selectedRegionIds, timeSignature.numerator, tracks]); + const showHybridButtonForAudio = showPianoRoll && pianoRollMode === 'midi-edit'; const showHybridButtonForMidi = showPianoRoll && pianoRollMode === 'spectrogram'; const beatTicksPerBar = Math.max(0, timeSignature.numerator - 1); @@ -307,6 +383,10 @@ const MainContent: React.FC = ({
diff --git a/src/components/global-track/GlobalChordLane.test.tsx b/src/components/global-track/GlobalChordLane.test.tsx index b006b34..51474a3 100644 --- a/src/components/global-track/GlobalChordLane.test.tsx +++ b/src/components/global-track/GlobalChordLane.test.tsx @@ -9,6 +9,10 @@ describe('GlobalChordLane', () => { beforeEach(() => { document.documentElement.style.setProperty('--track-grid-bar-width', '40'); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => null), + }); }); it('opens the chord popup for an existing region', () => { @@ -30,6 +34,7 @@ describe('GlobalChordLane', () => { onChangeChord={vi.fn()} onOpenPopup={onOpenPopup} onTabNavigate={vi.fn()} + onDropChordRegionsToTrack={vi.fn()} /> ); @@ -56,6 +61,7 @@ describe('GlobalChordLane', () => { onChangeChord={vi.fn()} onOpenPopup={vi.fn()} onTabNavigate={vi.fn()} + onDropChordRegionsToTrack={vi.fn()} /> ); @@ -98,6 +104,7 @@ describe('GlobalChordLane', () => { onChangeChord={vi.fn()} onOpenPopup={vi.fn()} onTabNavigate={vi.fn()} + onDropChordRegionsToTrack={vi.fn()} /> ); @@ -140,6 +147,7 @@ describe('GlobalChordLane', () => { onChangeChord={vi.fn()} onOpenPopup={vi.fn()} onTabNavigate={vi.fn()} + onDropChordRegionsToTrack={vi.fn()} /> ); @@ -147,4 +155,97 @@ describe('GlobalChordLane', () => { expect(onSelectRegion).toHaveBeenCalledWith('chord-1', { shiftKey: true, metaKey: true, ctrlKey: false }); }); + + it('drops the dragged selected chord onto a track row for import', () => { + const secondRegion = new KGChordRegion('chord-2', 'global-chord', 3, 'F', 4, 4); + const onDropChordRegionsToTrack = vi.fn(); + const trackGrid = document.createElement('div'); + trackGrid.className = 'track-grid'; + trackGrid.dataset.trackIndex = '2'; + document.body.appendChild(trackGrid); + vi.spyOn(document, 'elementFromPoint').mockReturnValue(trackGrid); + + render( + + ); + + const region = screen.getByText('Cmaj7').closest('.global-chord-region') as HTMLDivElement; + vi.spyOn(region, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 30, + bottom: 24, + width: 30, + height: 24, + toJSON: () => ({}), + }); + fireEvent.mouseDown(region, { clientX: 15, clientY: 10, button: 0 }); + fireEvent.mouseMove(window, { clientX: 15, clientY: 48 }); + fireEvent.mouseUp(window, { clientX: 15, clientY: 48 }); + + expect(onDropChordRegionsToTrack).toHaveBeenCalledWith('chord-1', 2); + document.body.removeChild(trackGrid); + }); + + it('still moves the chord horizontally when the drag ends back on the lane', () => { + const secondRegion = new KGChordRegion('chord-2', 'global-chord', 3, 'F', 4, 4); + const onMoveRegion = vi.fn(); + vi.spyOn(document, 'elementFromPoint').mockReturnValue(null); + + render( + + ); + + const region = screen.getByText('Cmaj7').closest('.global-chord-region') as HTMLDivElement; + vi.spyOn(region, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 30, + bottom: 24, + width: 30, + height: 24, + toJSON: () => ({}), + }); + fireEvent.mouseDown(region, { clientX: 15, clientY: 10, button: 0 }); + fireEvent.mouseMove(window, { clientX: 32, clientY: 10 }); + fireEvent.mouseUp(window, { clientX: 32, clientY: 10 }); + + expect(onMoveRegion).toHaveBeenCalledWith('chord-1', 2); + }); }); diff --git a/src/components/global-track/GlobalChordLane.tsx b/src/components/global-track/GlobalChordLane.tsx index a2265f6..f53c8a0 100644 --- a/src/components/global-track/GlobalChordLane.tsx +++ b/src/components/global-track/GlobalChordLane.tsx @@ -1,10 +1,14 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { FaPlus } from 'react-icons/fa'; +import { FaBan } from 'react-icons/fa6'; import { KGChordRegion } from '../../core/region/KGChordRegion'; import type { RegionClickOptions } from '../interfaces'; import { isModifierKeyPressed } from '../../util/osUtil'; import { TOOLBAR_CONSTANTS } from '../../constants'; import FloatingPopup from '../common/FloatingPopup'; import ChordPickerPopup from '../ChordPickerPopup'; +import { TrackType } from '../../core/track/KGTrack'; interface GlobalChordLaneProps { chordRegions: KGChordRegion[]; @@ -21,9 +25,11 @@ interface GlobalChordLaneProps { onChangeChord: (regionId: string, symbol: string) => void; onOpenPopup: (regionId: string) => void; onTabNavigate: (regionId: string, direction: 'forward' | 'backward') => void; + onDropChordRegionsToTrack?: (draggedRegionId: string, trackIndex: number) => void; } type ResizeEdge = 'start' | 'end' | null; +type DragFeedbackMode = 'move' | 'import' | 'blocked'; const REGION_EDGE_HITBOX_PX = 8; const DRAG_THRESHOLD_PX = 4; @@ -48,16 +54,19 @@ const GlobalChordLane: React.FC = ({ onChangeChord, onOpenPopup, onTabNavigate, + onDropChordRegionsToTrack, }) => { const laneRef = useRef(null); const [previewBeats, setPreviewBeats] = useState>({}); const [hoverEdges, setHoverEdges] = useState>({}); const [isModifierPressed, setIsModifierPressed] = useState(false); + const [dragFeedback, setDragFeedback] = useState<{ x: number; y: number; mode: DragFeedbackMode } | null>(null); const suppressClickSelectionRef = useRef(false); const interactionRef = useRef<{ mode: 'drag' | 'resize' | null; regionId: string; initialMouseX: number; + initialMouseY: number; initialStartBeat: number; initialLength: number; resizeEdge: ResizeEdge; @@ -116,6 +125,32 @@ const GlobalChordLane: React.FC = ({ return null; }; + const getTrackDropTarget = (clientX: number, clientY: number): { trackIndex: number; trackType: string | null } | null => { + if (typeof document.elementFromPoint !== 'function') { + return null; + } + + const dropTarget = document.elementFromPoint(clientX, clientY)?.closest('.track-grid'); + if (!(dropTarget instanceof HTMLElement)) { + return null; + } + + const rawTrackIndex = dropTarget.dataset.trackIndex; + if (!rawTrackIndex) { + return null; + } + + const trackIndex = Number(rawTrackIndex); + if (!Number.isInteger(trackIndex)) { + return null; + } + + return { + trackIndex, + trackType: dropTarget.dataset.trackType ?? null, + }; + }; + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (isModifierKeyPressed(event)) { @@ -146,7 +181,8 @@ const GlobalChordLane: React.FC = ({ const interaction = interactionRef.current; const deltaX = event.clientX - interaction.initialMouseX; - if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) { + const deltaY = event.clientY - interaction.initialMouseY; + if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX || Math.abs(deltaY) >= DRAG_THRESHOLD_PX) { interaction.moved = true; } @@ -159,6 +195,28 @@ const GlobalChordLane: React.FC = ({ length: interaction.initialLength, }, }); + + if (!interaction.moved) { + setDragFeedback(null); + document.body.style.cursor = ''; + return; + } + + const dropTarget = getTrackDropTarget(event.clientX, event.clientY); + if (dropTarget?.trackType === TrackType.MIDI) { + setDragFeedback({ x: event.clientX, y: event.clientY, mode: 'import' }); + document.body.style.cursor = 'copy'; + return; + } + + if (dropTarget) { + setDragFeedback({ x: event.clientX, y: event.clientY, mode: 'blocked' }); + document.body.style.cursor = 'not-allowed'; + return; + } + + setDragFeedback({ x: event.clientX, y: event.clientY, mode: 'move' }); + document.body.style.cursor = 'grabbing'; return; } @@ -196,6 +254,8 @@ const GlobalChordLane: React.FC = ({ const interaction = interactionRef.current; interactionRef.current = null; + setDragFeedback(null); + document.body.style.cursor = ''; if (!interaction.moved) { setPreviewBeats({}); @@ -212,6 +272,12 @@ const GlobalChordLane: React.FC = ({ } if (interaction.mode === 'drag') { + const dropTarget = getTrackDropTarget(event.clientX, event.clientY); + if (dropTarget !== null) { + onDropChordRegionsToTrack?.(interaction.regionId, dropTarget.trackIndex); + return; + } + onMoveRegion(interaction.regionId, preview.startBeat); return; } @@ -229,8 +295,10 @@ const GlobalChordLane: React.FC = ({ return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); + setDragFeedback(null); + document.body.style.cursor = ''; }; - }, [beatWidth, onMoveRegion, onResizeRegion, onSelectRegion, previewBeats]); + }, [beatWidth, onDropChordRegionsToTrack, onMoveRegion, onResizeRegion, onSelectRegion, previewBeats]); const handleLaneMouseDown = (event: React.MouseEvent) => { if (event.button !== 0) { @@ -265,13 +333,14 @@ const GlobalChordLane: React.FC = ({ }; return ( -
- {chordRegions.map(region => { + <> +
+ {chordRegions.map(region => { const { startBeat, length } = getRenderedBeatState(region); const isSelected = selectedRegionIds.includes(region.getId()); const left = startBeat * beatWidth; @@ -324,6 +393,9 @@ const GlobalChordLane: React.FC = ({ if (event.button !== 0) { return; } + if ((event.target as HTMLElement).closest('.global-chord-drag-handle')) { + return; + } event.preventDefault(); event.stopPropagation(); @@ -333,6 +405,7 @@ const GlobalChordLane: React.FC = ({ mode: resizeEdge ? 'resize' : 'drag', regionId: region.getId(), initialMouseX: event.clientX, + initialMouseY: event.clientY, initialStartBeat: region.getStartFromBeat(), initialLength: region.getLength(), resizeEdge, @@ -368,8 +441,27 @@ const GlobalChordLane: React.FC = ({
); - })} -
+ })} +
+ {dragFeedback && createPortal( +
+ + {dragFeedback.mode === 'blocked' ? : } + + + {dragFeedback.mode === 'import' + ? 'Convert to MIDI' + : dragFeedback.mode === 'blocked' + ? 'Audio tracks not supported' + : 'Move chord'} + +
, + document.body + )} + ); }; diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index 99cf731..54b5d9f 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -10,6 +10,7 @@ import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { useProjectStore } from '../../stores/projectStore'; import { isModifierKeyPressed } from '../../util/osUtil'; +import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil'; interface RegionResizePreviewBaseline { regionId: string; @@ -52,6 +53,7 @@ interface TrackGridItemProps { onOpenHybrid?: (regionId: string) => void; allTracks?: KGTrack[]; // Added to access all tracks for drag operations onKGOneClipDrop?: (e: React.DragEvent, trackIndex: number) => void; + onChordRegionDrop?: (e: React.DragEvent, trackIndex: number) => void; previewRegionStyles?: Record; setPreviewRegionStyles?: React.Dispatch>>; previewRegionContentStyles?: Record; @@ -82,6 +84,7 @@ const TrackGridItem: React.FC = ({ onOpenHybrid, allTracks, onKGOneClipDrop, + onChordRegionDrop, previewRegionStyles, setPreviewRegionStyles, previewRegionContentStyles, @@ -721,6 +724,8 @@ const TrackGridItem: React.FC = ({
{ if (!isAutomationActive) { onDoubleClick(e, index); @@ -733,7 +738,10 @@ const TrackGridItem: React.FC = ({ }} ref={trackElementRef} onDragOver={(e) => { - if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) { + if ( + Array.from(e.dataTransfer.types).includes('application/kgone-clip') + || Array.from(e.dataTransfer.types).includes(CHORD_REGION_IMPORT_MIME_TYPE) + ) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } @@ -742,6 +750,12 @@ const TrackGridItem: React.FC = ({ if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) { e.preventDefault(); onKGOneClipDrop?.(e, index); + return; + } + + if (Array.from(e.dataTransfer.types).includes(CHORD_REGION_IMPORT_MIME_TYPE)) { + e.preventDefault(); + onChordRegionDrop?.(e, index); } }} > diff --git a/src/components/track/TrackGridPanel.test.tsx b/src/components/track/TrackGridPanel.test.tsx index b3ef39e..0663d1d 100644 --- a/src/components/track/TrackGridPanel.test.tsx +++ b/src/components/track/TrackGridPanel.test.tsx @@ -3,9 +3,19 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render } from '@testing-library/react'; import TrackGridPanel from './TrackGridPanel'; import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data'; +import { KGAudioTrack } from '../../core/track/KGAudioTrack'; +import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil'; +import { KGChordRegion } from '../../core/region/KGChordRegion'; +import { KGMidiNote } from '../../core/midi/KGMidiNote'; const executeCommandMock = vi.fn(); const getCreatedRegionMock = vi.fn(); +const showAlertMock = vi.fn(); +const globalChordRegions = [ + new KGChordRegion('chord-1', 'global-chord', 3, 'C', 0, 4), + new KGChordRegion('chord-2', 'global-chord', 3, 'F', 4, 4), +]; +let currentTracks: Array | KGAudioTrack> = []; vi.mock('../../stores/projectStore', () => ({ useProjectStore: (selector?: (state: { @@ -32,15 +42,32 @@ vi.mock('../common', () => ({ vi.mock('../../core/KGCore', () => ({ KGCore: { instance: () => ({ - executeCommand: executeCommandMock, + executeCommand: (command: { execute?: () => void }) => { + command.execute?.(); + executeCommandMock(command); + }, + getCurrentProject: () => ({ + getTracks: () => currentTracks, + getGlobalTracks: () => [{ + getRegions: () => globalChordRegions, + }], + }), }), }, })); -vi.mock('../../util/miscUtil', () => ({ - generateNewRegionName: () => 'New Region', +vi.mock('../../util/dialogUtil', () => ({ + showAlert: (...args: unknown[]) => showAlertMock(...args), })); +vi.mock('../../util/miscUtil', async () => { + const actual = await vi.importActual('../../util/miscUtil'); + return { + ...actual, + generateNewRegionName: () => 'New Region', + }; +}); + vi.mock('../../core/commands', async () => { const actual = await vi.importActual('../../core/commands'); return { @@ -88,6 +115,7 @@ describe('TrackGridPanel lasso selection', () => { const trackB = createMockMidiTrack({ id: 2, regions: [regionB] }); trackA.setTrackIndex(0); trackB.setTrackIndex(1); + currentTracks = [trackA, trackB]; const onRegionLassoSelection = vi.fn(); const onRegionCreated = vi.fn(); @@ -131,6 +159,9 @@ describe('TrackGridPanel lasso selection', () => { vi.restoreAllMocks(); executeCommandMock.mockReset(); getCreatedRegionMock.mockReset(); + showAlertMock.mockReset(); + globalChordRegions[0].setSymbol('C'); + currentTracks = []; }); it('selects intersecting regions across multiple track rows', () => { @@ -205,4 +236,111 @@ describe('TrackGridPanel lasso selection', () => { expect(onRegionCreated).toHaveBeenCalledTimes(1); }); }); + + it('imports chord regions into a MIDI track on drop', async () => { + const regionA = createMockMidiRegion({ id: 'region-a', trackId: '1', trackIndex: 0, startFromBeat: 0, length: 4 }); + const trackA = createMockMidiTrack({ id: 1, regions: [regionA] }); + trackA.setTrackIndex(0); + currentTracks = [trackA]; + const onExternalDropComplete = vi.fn(); + + const view = render( + + ); + + const targetGrid = view.container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement; + fireEvent.drop(targetGrid, { + dataTransfer: { + types: [CHORD_REGION_IMPORT_MIME_TYPE], + getData: () => JSON.stringify({ draggedRegionId: 'chord-1', selectedRegionIds: ['chord-1', 'chord-2'] }), + }, + }); + + await vi.waitFor(() => { + expect(onExternalDropComplete).toHaveBeenCalledTimes(1); + }); + + const command = executeCommandMock.mock.calls.at(-1)?.[0]; + expect(command.getDescription()).toContain('Import chord progression'); + const createdRegion = command.getCreatedRegion(); + expect(createdRegion?.getStartFromBeat()).toBe(0); + expect(createdRegion?.getLength()).toBe(8); + expect(createdRegion?.getNotes().map((note: KGMidiNote) => note.getPitch())).toEqual([48, 60, 64, 67, 41, 53, 57, 60]); + }); + + it('shows a polite dialog when dropping chord regions onto an audio track', async () => { + const audioTrack = new KGAudioTrack('Audio Track', 2); + audioTrack.setTrackIndex(0); + currentTracks = [audioTrack]; + + const view = render( + + ); + + const targetGrid = view.container.querySelector('[data-test-id="track-grid-2"]') as HTMLDivElement; + fireEvent.drop(targetGrid, { + dataTransfer: { + types: [CHORD_REGION_IMPORT_MIME_TYPE], + getData: () => JSON.stringify({ draggedRegionId: 'chord-1', selectedRegionIds: ['chord-1'] }), + }, + }); + + await vi.waitFor(() => { + expect(showAlertMock).toHaveBeenCalledWith('Chord regions can only be converted into MIDI tracks. Please drop them onto a MIDI track.'); + }); + }); + + it('shows a polite dialog when chord parsing fails', async () => { + const trackA = createMockMidiTrack({ id: 1, regions: [] }); + trackA.setTrackIndex(0); + currentTracks = [trackA]; + globalChordRegions[0].setSymbol('not-a-chord'); + + const view = render( + + ); + + const targetGrid = view.container.querySelector('[data-test-id="track-grid-1"]') as HTMLDivElement; + fireEvent.drop(targetGrid, { + dataTransfer: { + types: [CHORD_REGION_IMPORT_MIME_TYPE], + getData: () => JSON.stringify({ draggedRegionId: 'chord-1', selectedRegionIds: ['chord-1'] }), + }, + }); + + await vi.waitFor(() => { + expect(showAlertMock).toHaveBeenCalledWith('Unable to import chord "not-a-chord". Please update the chord symbol and try again.'); + }); + }); }); diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index eaacb28..a5f3280 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -8,7 +8,7 @@ import type { RegionClickOptions, RegionPreviewContentStyle, RegionUI } from '.. import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, REGION_CONSTANTS } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; -import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand, MoveMultipleRegionsCommand, ResizeMultipleRegionsCommand, ImportAudioCommand, ImportMidiClipCommand } from '../../core/commands'; +import { CreateRegionCommand, ResizeRegionCommand, MoveRegionCommand, MoveMultipleRegionsCommand, ResizeMultipleRegionsCommand, ImportAudioCommand, ImportMidiClipCommand, ImportChordRegionsCommand } from '../../core/commands'; import { KGCore } from '../../core/KGCore'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioRegion } from '../../core/region/KGAudioRegion'; @@ -19,6 +19,13 @@ import { parseMidiFirstTrackNotes } from '../../util/midiUtil'; import * as Tone from 'tone'; import { useProjectStore } from '../../stores/projectStore'; import { getAudioRegionDisplayLengthBeats } from '../../util/globalTrackUtil'; +import { KGChordRegion } from '../../core/region/KGChordRegion'; +import { + buildChordRegionImportPlan, + CHORD_REGION_IMPORT_MIME_TYPE, + CHORD_REGION_IMPORT_REGION_NAME, + type ChordRegionImportPayload, +} from '../../util/chordRegionImportUtil'; const getRegionClickOptions = (event: Pick): RegionClickOptions => ({ shiftKey: event.shiftKey, @@ -927,6 +934,84 @@ const TrackGridPanel: React.FC = ({ } }; + const handleChordRegionDrop = async (e: React.DragEvent, trackIndex: number) => { + const raw = e.dataTransfer.getData(CHORD_REGION_IMPORT_MIME_TYPE); + if (!raw) { + await showAlert('Unable to import the chord progression from this drag operation. Please try again.'); + return; + } + + let payload: ChordRegionImportPayload; + try { + payload = JSON.parse(raw) as ChordRegionImportPayload; + } catch { + await showAlert('Unable to read the dragged chord progression. Please try again.'); + return; + } + + const track = tracks[trackIndex]; + if (!track) { + await showAlert('Unable to find the destination track for this import.'); + return; + } + + if (track.getType() !== TrackType.MIDI) { + await showAlert('Chord regions can only be converted into MIDI tracks. Please drop them onto a MIDI track.'); + return; + } + + const chordRegionIds = Array.from(new Set(payload.selectedRegionIds ?? [])); + const chordRegions = chordRegionIds.map(regionId => { + const region = KGCore.instance().getCurrentProject().getGlobalTracks() + .flatMap(globalTrack => globalTrack.getRegions()) + .find(candidate => candidate.getId() === regionId); + return region instanceof KGChordRegion ? region : null; + }).filter((region): region is KGChordRegion => region !== null); + + if (chordRegions.length === 0) { + await showAlert('No chord regions were available to import. Please select a chord region and try again.'); + return; + } + + const planResult = buildChordRegionImportPlan(chordRegions); + if (!planResult.ok) { + await showAlert(planResult.error.message); + return; + } + + try { + const command = new ImportChordRegionsCommand( + track.getId().toString(), + trackIndex, + planResult.plan.startBeat, + planResult.plan.lengthInBeats, + planResult.plan.notes, + CHORD_REGION_IMPORT_REGION_NAME, + ); + KGCore.instance().executeCommand(command, { rethrow: true }); + + const created = command.getCreatedRegion(); + if (!created || !onExternalDropComplete) { + refreshProjectState(); + return; + } + + const beatsPerBar = timeSignature.numerator; + const regionUI: RegionUI = { + id: created.getId(), + trackId: track.getId().toString(), + trackIndex, + barNumber: (created.getStartFromBeat() / beatsPerBar) + 1, + length: created.getLength() / beatsPerBar, + name: created.getName(), + }; + onExternalDropComplete(trackIndex, regionUI); + } catch (error) { + console.error('[ChordImport] Drop import failed:', error); + await showAlert('Unable to import the selected chord regions into a MIDI region. Please try again.'); + } + }; + return (
{/* Playhead */} @@ -959,6 +1044,7 @@ const TrackGridPanel: React.FC = ({ onOpenHybrid={onOpenHybrid} allTracks={tracks} onKGOneClipDrop={handleExternalDrop} + onChordRegionDrop={handleChordRegionDrop} previewRegionStyles={previewRegionStyles} setPreviewRegionStyles={setPreviewRegionStyles} previewRegionContentStyles={previewRegionContentStyles} diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index 2fe9532..346259d 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -29,6 +29,7 @@ export { PasteRegionsCommand } from './region/PasteRegionsCommand'; export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand'; export { ImportAudioCommand } from './region/ImportAudioCommand'; export { ImportMidiClipCommand } from './region/ImportMidiClipCommand'; +export { ImportChordRegionsCommand } from './region/ImportChordRegionsCommand'; export { ImportStemsCommand } from './region/ImportStemsCommand'; export type { StemImportEntry } from './region/ImportStemsCommand'; export { SplitRegionCommand } from './region/SplitRegionCommand'; diff --git a/src/core/commands/region/ImportChordRegionsCommand.test.ts b/src/core/commands/region/ImportChordRegionsCommand.test.ts new file mode 100644 index 0000000..12d9bbb --- /dev/null +++ b/src/core/commands/region/ImportChordRegionsCommand.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ImportChordRegionsCommand } from './ImportChordRegionsCommand'; +import { createMockMidiTrack } from '../../../test/utils/mock-data'; + +const track = createMockMidiTrack({ id: 1, regions: [] }); +track.setTrackIndex(0); + +vi.mock('../../KGCore', () => ({ + KGCore: { + instance: () => ({ + getCurrentProject: () => ({ + getTracks: () => [track], + }), + }), + }, +})); + +describe('ImportChordRegionsCommand', () => { + beforeEach(() => { + track.setRegions([]); + }); + + it('creates one MIDI region with imported notes and supports undo', () => { + const command = new ImportChordRegionsCommand( + '1', + 0, + 8, + 6, + [ + { startBeat: 0, endBeat: 4, pitch: 48, velocity: 127 }, + { startBeat: 0, endBeat: 4, pitch: 60, velocity: 127 }, + { startBeat: 4, endBeat: 6, pitch: 41, velocity: 127 }, + { startBeat: 4, endBeat: 6, pitch: 53, velocity: 127 }, + ], + 'Chord Progression', + 'imported-region', + ); + + command.execute(); + + expect(track.getRegions()).toHaveLength(1); + const region = command.getCreatedRegion(); + expect(region?.getId()).toBe('imported-region'); + expect(region?.getStartFromBeat()).toBe(8); + expect(region?.getLength()).toBe(6); + expect(region?.getNotes().map(note => ({ + startBeat: note.getStartBeat(), + endBeat: note.getEndBeat(), + pitch: note.getPitch(), + velocity: note.getVelocity(), + }))).toEqual([ + { startBeat: 0, endBeat: 4, pitch: 48, velocity: 127 }, + { startBeat: 0, endBeat: 4, pitch: 60, velocity: 127 }, + { startBeat: 4, endBeat: 6, pitch: 41, velocity: 127 }, + { startBeat: 4, endBeat: 6, pitch: 53, velocity: 127 }, + ]); + + command.undo(); + expect(track.getRegions()).toHaveLength(0); + }); +}); diff --git a/src/core/commands/region/ImportChordRegionsCommand.ts b/src/core/commands/region/ImportChordRegionsCommand.ts new file mode 100644 index 0000000..da29828 --- /dev/null +++ b/src/core/commands/region/ImportChordRegionsCommand.ts @@ -0,0 +1,88 @@ +import { KGCommand } from '../KGCommand'; +import { KGCore } from '../../KGCore'; +import { KGMidiRegion } from '../../region/KGMidiRegion'; +import { KGMidiNote } from '../../midi/KGMidiNote'; +import { generateUniqueId } from '../../../util/miscUtil'; +import { + CHORD_REGION_IMPORT_REGION_NAME, + type ImportedChordMidiNoteData, +} from '../../../util/chordRegionImportUtil'; + +export class ImportChordRegionsCommand extends KGCommand { + private trackId: string; + private trackIndex: number; + private regionId: string; + private regionName: string; + private startBeat: number; + private lengthInBeats: number; + private notes: ImportedChordMidiNoteData[]; + private createdRegion: KGMidiRegion | null = null; + + constructor( + trackId: string, + trackIndex: number, + startBeat: number, + lengthInBeats: number, + notes: ImportedChordMidiNoteData[], + regionName: string = CHORD_REGION_IMPORT_REGION_NAME, + regionId?: string, + ) { + super(); + this.trackId = trackId; + this.trackIndex = trackIndex; + this.startBeat = startBeat; + this.lengthInBeats = lengthInBeats; + this.notes = notes; + this.regionId = regionId ?? generateUniqueId('KGMidiRegion'); + this.regionName = regionName; + } + + execute(): void { + const track = KGCore.instance().getCurrentProject().getTracks().find( + candidate => candidate.getId().toString() === this.trackId, + ); + if (!track) { + throw new Error(`Track ${this.trackId} not found`); + } + + this.createdRegion = new KGMidiRegion( + this.regionId, + this.trackId, + this.trackIndex, + this.regionName, + this.startBeat, + this.lengthInBeats, + ); + + this.notes.forEach(noteData => { + this.createdRegion?.addNote(new KGMidiNote( + generateUniqueId('KGMidiNote'), + noteData.startBeat, + noteData.endBeat, + noteData.pitch, + noteData.velocity, + )); + }); + + track.addRegion(this.createdRegion); + } + + undo(): void { + const track = KGCore.instance().getCurrentProject().getTracks().find( + candidate => candidate.getId().toString() === this.trackId, + ); + if (!track) { + throw new Error(`Track ${this.trackId} not found during undo`); + } + + track.removeRegion(this.regionId); + } + + getDescription(): string { + return `Import chord progression "${this.regionName}"`; + } + + getCreatedRegion(): KGMidiRegion | null { + return this.createdRegion; + } +} diff --git a/src/util/chordRegionImportUtil.ts b/src/util/chordRegionImportUtil.ts new file mode 100644 index 0000000..71939f6 --- /dev/null +++ b/src/util/chordRegionImportUtil.ts @@ -0,0 +1,130 @@ +import { Chord, Note } from 'tonal'; +import { KGChordRegion } from '../core/region/KGChordRegion'; +import { getChordMidiPitches, parseChordSymbol } from './chordUtil'; + +export const CHORD_REGION_IMPORT_MIME_TYPE = 'application/kgstudio-chord-region'; +export const CHORD_REGION_IMPORT_VELOCITY = 127; +export const CHORD_REGION_IMPORT_REGION_NAME = 'Chord Progression'; + +export interface ChordRegionImportPayload { + draggedRegionId: string; + selectedRegionIds: string[]; +} + +export interface ImportedChordMidiNoteData { + startBeat: number; + endBeat: number; + pitch: number; + velocity: number; +} + +export interface ChordRegionImportPlan { + sourceRegionIds: string[]; + startBeat: number; + lengthInBeats: number; + notes: ImportedChordMidiNoteData[]; +} + +export interface ChordRegionImportError { + message: string; +} + +export type ChordRegionImportPlanResult = + | { ok: true; plan: ChordRegionImportPlan } + | { ok: false; error: ChordRegionImportError }; + +export function resolveChordRegionImportSelection( + draggedRegionId: string, + selectedRegionIds: string[], +): string[] { + return selectedRegionIds.includes(draggedRegionId) + ? selectedRegionIds + : [draggedRegionId]; +} + +function getBaseRootMidi(symbol: string): number | null { + const descriptor = parseChordSymbol(symbol); + if (!descriptor) { + return null; + } + + const rootLetter = descriptor.root.charAt(0).toUpperCase(); + const octave = ['F', 'G', 'A', 'B'].includes(rootLetter) ? 3 : 4; + return Note.midi(`${descriptor.root}${octave}`); +} + +export function convertChordSymbolToMidiPitches(symbol: string): number[] | null { + const descriptor = parseChordSymbol(symbol); + if (!descriptor) { + return null; + } + + const tonalChord = Chord.get(descriptor.symbol); + if (tonalChord.empty) { + return null; + } + + const rootMidi = getBaseRootMidi(descriptor.symbol); + if (rootMidi === null) { + return null; + } + + const midiPitches = getChordMidiPitches(descriptor.symbol, rootMidi); + if (midiPitches.length === 0) { + return null; + } + + return [rootMidi - 12, ...midiPitches]; +} + +export function buildChordRegionImportPlan( + chordRegions: KGChordRegion[], +): ChordRegionImportPlanResult { + if (chordRegions.length === 0) { + return { + ok: false, + error: { message: 'No chord regions were available to import.' }, + }; + } + + const sortedRegions = [...chordRegions].sort((left, right) => ( + left.getStartFromBeat() - right.getStartFromBeat() + )); + const firstRegion = sortedRegions[0]; + const lastRegion = sortedRegions[sortedRegions.length - 1]; + const startBeat = firstRegion.getStartFromBeat(); + const endBeat = lastRegion.getStartFromBeat() + lastRegion.getLength(); + const notes: ImportedChordMidiNoteData[] = []; + + for (const region of sortedRegions) { + const midiPitches = convertChordSymbolToMidiPitches(region.getSymbol()); + if (!midiPitches) { + return { + ok: false, + error: { message: `Unable to import chord "${region.getSymbol()}". Please update the chord symbol and try again.` }, + }; + } + + const noteStartBeat = region.getStartFromBeat() - startBeat; + const noteEndBeat = noteStartBeat + region.getLength(); + + midiPitches.forEach(pitch => { + notes.push({ + startBeat: noteStartBeat, + endBeat: noteEndBeat, + pitch, + velocity: CHORD_REGION_IMPORT_VELOCITY, + }); + }); + } + + return { + ok: true, + plan: { + sourceRegionIds: sortedRegions.map(region => region.getId()), + startBeat, + lengthInBeats: endBeat - startBeat, + notes, + }, + }; +} diff --git a/src/util/chordUtil.test.ts b/src/util/chordUtil.test.ts index 9dba207..f6a9ab3 100644 --- a/src/util/chordUtil.test.ts +++ b/src/util/chordUtil.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildChordSymbol, formatChordSymbolForDisplay, getChordMidiPitches, getChordPitchClasses, parseChordSymbol } from './chordUtil'; +import { buildChordRegionImportPlan, convertChordSymbolToMidiPitches } from './chordRegionImportUtil'; +import { KGChordRegion } from '../core/region/KGChordRegion'; describe('chordUtil', () => { it('parses half-diminished chords into the popup descriptor shape', () => { @@ -41,4 +43,92 @@ describe('chordUtil', () => { expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)'); expect(formatChordSymbolForDisplay('Bbmaj7#11')).toBe('B♭maj7(♯11)'); }); + + it('maps C-root chords into the C4-C5 range', () => { + expect(convertChordSymbolToMidiPitches('C')).toEqual([48, 60, 64, 67]); + }); + + it('maps F-root chords down to the lower octave range', () => { + expect(convertChordSymbolToMidiPitches('F')).toEqual([41, 53, 57, 60]); + }); + + it('maps common progression chords into the expected octave ranges', () => { + expect(convertChordSymbolToMidiPitches('Am')).toEqual([45, 57, 60, 64]); + expect(convertChordSymbolToMidiPitches('Dm')).toEqual([50, 62, 65, 69]); + expect(convertChordSymbolToMidiPitches('E7')).toEqual([52, 64, 68, 71, 74]); + expect(convertChordSymbolToMidiPitches('Bm7b5')).toEqual([47, 59, 62, 65, 69]); + }); + + it('builds a multi-region import plan using timeline-relative note placement', () => { + const chordA = new KGChordRegion('chord-1', 'global-chord', 3, 'C', 8, 4); + const chordB = new KGChordRegion('chord-2', 'global-chord', 3, 'F', 12, 2); + const result = buildChordRegionImportPlan([chordB, chordA]); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.plan.startBeat).toBe(8); + expect(result.plan.lengthInBeats).toBe(6); + expect(result.plan.sourceRegionIds).toEqual(['chord-1', 'chord-2']); + expect(result.plan.notes).toEqual([ + { startBeat: 0, endBeat: 4, pitch: 48, velocity: 127 }, + { startBeat: 0, endBeat: 4, pitch: 60, velocity: 127 }, + { startBeat: 0, endBeat: 4, pitch: 64, velocity: 127 }, + { startBeat: 0, endBeat: 4, pitch: 67, velocity: 127 }, + { startBeat: 4, endBeat: 6, pitch: 41, velocity: 127 }, + { startBeat: 4, endBeat: 6, pitch: 53, velocity: 127 }, + { startBeat: 4, endBeat: 6, pitch: 57, velocity: 127 }, + { startBeat: 4, endBeat: 6, pitch: 60, velocity: 127 }, + ]); + }); + + it('builds an import plan for a more complex chord progression', () => { + const chordA = new KGChordRegion('chord-1', 'global-chord', 3, 'Am', 0, 4); + const chordB = new KGChordRegion('chord-2', 'global-chord', 3, 'Dm', 4, 4); + const chordC = new KGChordRegion('chord-3', 'global-chord', 3, 'E7', 8, 4); + const chordD = new KGChordRegion('chord-4', 'global-chord', 3, 'Bm7b5', 12, 4); + const result = buildChordRegionImportPlan([chordD, chordB, chordA, chordC]); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + + expect(result.plan.startBeat).toBe(0); + expect(result.plan.lengthInBeats).toBe(16); + expect(result.plan.sourceRegionIds).toEqual(['chord-1', 'chord-2', 'chord-3', 'chord-4']); + expect(result.plan.notes).toEqual([ + { startBeat: 0, endBeat: 4, pitch: 45, velocity: 127 }, + { startBeat: 0, endBeat: 4, pitch: 57, velocity: 127 }, + { startBeat: 0, endBeat: 4, pitch: 60, velocity: 127 }, + { startBeat: 0, endBeat: 4, pitch: 64, velocity: 127 }, + { startBeat: 4, endBeat: 8, pitch: 50, velocity: 127 }, + { startBeat: 4, endBeat: 8, pitch: 62, velocity: 127 }, + { startBeat: 4, endBeat: 8, pitch: 65, velocity: 127 }, + { startBeat: 4, endBeat: 8, pitch: 69, velocity: 127 }, + { startBeat: 8, endBeat: 12, pitch: 52, velocity: 127 }, + { startBeat: 8, endBeat: 12, pitch: 64, velocity: 127 }, + { startBeat: 8, endBeat: 12, pitch: 68, velocity: 127 }, + { startBeat: 8, endBeat: 12, pitch: 71, velocity: 127 }, + { startBeat: 8, endBeat: 12, pitch: 74, velocity: 127 }, + { startBeat: 12, endBeat: 16, pitch: 47, velocity: 127 }, + { startBeat: 12, endBeat: 16, pitch: 59, velocity: 127 }, + { startBeat: 12, endBeat: 16, pitch: 62, velocity: 127 }, + { startBeat: 12, endBeat: 16, pitch: 65, velocity: 127 }, + { startBeat: 12, endBeat: 16, pitch: 69, velocity: 127 }, + ]); + }); + + it('returns structured failure metadata for unsupported symbols', () => { + const badChord = new KGChordRegion('chord-1', 'global-chord', 3, 'not-a-chord', 0, 4); + const result = buildChordRegionImportPlan([badChord]); + + expect(result.ok).toBe(false); + if (result.ok) { + return; + } + expect(result.error.message).toContain('Unable to import chord'); + }); });