feat: added global chord region to MIDI region conversion feature

This commit is contained in:
Xiaohan-Tian
2026-05-25 16:25:43 -07:00
parent e49b675985
commit 8aa36d2368
12 changed files with 941 additions and 16 deletions
+44
View File
@@ -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);
+80
View File
@@ -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<MainContentProps> = ({
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<MainContentProps> = ({
<MainContentGlobalTracksSection
visible={showGlobalTracksMock}
{...mainContentGlobalTracks.sectionProps}
chordLaneProps={{
...mainContentGlobalTracks.sectionProps.chordLaneProps,
onDropChordRegionsToTrack: handleGlobalChordDropToTrack,
}}
/>
<div className="main-content-body" onClick={mainContentRegions.handleEmptyMainContentClick}>
@@ -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(
<GlobalChordLane
chordRegions={[baseRegion, secondRegion]}
maxBars={8}
barWidthMultiplier={1}
timeSignature={{ numerator: 4, denominator: 4 }}
selectedRegionIds={['chord-1', 'chord-2']}
popupRegionId={null}
onClosePopup={vi.fn()}
onSelectRegion={vi.fn()}
onCreateAtBeat={vi.fn()}
onMoveRegion={vi.fn()}
onResizeRegion={vi.fn()}
onChangeChord={vi.fn()}
onOpenPopup={vi.fn()}
onTabNavigate={vi.fn()}
onDropChordRegionsToTrack={onDropChordRegionsToTrack}
/>
);
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(
<GlobalChordLane
chordRegions={[baseRegion, secondRegion]}
maxBars={8}
barWidthMultiplier={1}
timeSignature={{ numerator: 4, denominator: 4 }}
selectedRegionIds={['chord-2']}
popupRegionId={null}
onClosePopup={vi.fn()}
onSelectRegion={vi.fn()}
onCreateAtBeat={vi.fn()}
onMoveRegion={onMoveRegion}
onResizeRegion={vi.fn()}
onChangeChord={vi.fn()}
onOpenPopup={vi.fn()}
onTabNavigate={vi.fn()}
onDropChordRegionsToTrack={vi.fn()}
/>
);
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);
});
});
@@ -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<GlobalChordLaneProps> = ({
onChangeChord,
onOpenPopup,
onTabNavigate,
onDropChordRegionsToTrack,
}) => {
const laneRef = useRef<HTMLDivElement | null>(null);
const [previewBeats, setPreviewBeats] = useState<Record<string, { startBeat: number; length: number }>>({});
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
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<GlobalChordLaneProps> = ({
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<GlobalChordLaneProps> = ({
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<GlobalChordLaneProps> = ({
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<GlobalChordLaneProps> = ({
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<GlobalChordLaneProps> = ({
}
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<GlobalChordLaneProps> = ({
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<HTMLDivElement>) => {
if (event.button !== 0) {
@@ -265,6 +333,7 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
};
return (
<>
<div
ref={laneRef}
className={`global-marker-lane global-chord-lane${popupRegionId ? ' popup-open' : ''}${isModifierPressed ? ' pencil-cursor' : ''}`}
@@ -324,6 +393,9 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
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<GlobalChordLaneProps> = ({
mode: resizeEdge ? 'resize' : 'drag',
regionId: region.getId(),
initialMouseX: event.clientX,
initialMouseY: event.clientY,
initialStartBeat: region.getStartFromBeat(),
initialLength: region.getLength(),
resizeEdge,
@@ -370,6 +443,25 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
);
})}
</div>
{dragFeedback && createPortal(
<div
className={`global-chord-drag-feedback global-chord-drag-feedback-${dragFeedback.mode}`}
style={{ left: `${dragFeedback.x + 14}px`, top: `${dragFeedback.y + 14}px` }}
>
<span className="global-chord-drag-feedback-icon">
{dragFeedback.mode === 'blocked' ? <FaBan /> : <FaPlus />}
</span>
<span className="global-chord-drag-feedback-label">
{dragFeedback.mode === 'import'
? 'Convert to MIDI'
: dragFeedback.mode === 'blocked'
? 'Audio tracks not supported'
: 'Move chord'}
</span>
</div>,
document.body
)}
</>
);
};
+15 -1
View File
@@ -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<HTMLDivElement>, trackIndex: number) => void;
onChordRegionDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
previewRegionStyles?: Record<string, React.CSSProperties>;
setPreviewRegionStyles?: React.Dispatch<React.SetStateAction<Record<string, React.CSSProperties>>>;
previewRegionContentStyles?: Record<string, RegionPreviewContentStyle>;
@@ -82,6 +84,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onOpenHybrid,
allTracks,
onKGOneClipDrop,
onChordRegionDrop,
previewRegionStyles,
setPreviewRegionStyles,
previewRegionContentStyles,
@@ -721,6 +724,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
<div
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''} ${isAutomationActive ? 'automation-active' : ''}`}
data-test-id={`track-grid-${track.getId()}`}
data-track-index={index}
data-track-type={track.getType()}
onDoubleClick={(e) => {
if (!isAutomationActive) {
onDoubleClick(e, index);
@@ -733,7 +738,10 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
}}
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<TrackGridItemProps> = ({
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);
}
}}
>
+141 -3
View File
@@ -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<ReturnType<typeof createMockMidiTrack> | 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<typeof import('../../util/miscUtil')>('../../util/miscUtil');
return {
...actual,
generateNewRegionName: () => 'New Region',
};
});
vi.mock('../../core/commands', async () => {
const actual = await vi.importActual<typeof import('../../core/commands')>('../../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(
<TrackGridPanel
tracks={[trackA]}
regions={[{ id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 1, length: 1, name: 'Region A' }]}
maxBars={8}
timeSignature={{ numerator: 4, denominator: 4 }}
draggedTrackIndex={null}
dragOverTrackIndex={null}
selectedRegionId={null}
projectName="Test"
onRegionCreated={vi.fn()}
onExternalDropComplete={onExternalDropComplete}
/>
);
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(
<TrackGridPanel
tracks={[audioTrack]}
regions={[]}
maxBars={8}
timeSignature={{ numerator: 4, denominator: 4 }}
draggedTrackIndex={null}
dragOverTrackIndex={null}
selectedRegionId={null}
projectName="Test"
onRegionCreated={vi.fn()}
/>
);
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(
<TrackGridPanel
tracks={[trackA]}
regions={[]}
maxBars={8}
timeSignature={{ numerator: 4, denominator: 4 }}
draggedTrackIndex={null}
dragOverTrackIndex={null}
selectedRegionId={null}
projectName="Test"
onRegionCreated={vi.fn()}
/>
);
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.');
});
});
});
+87 -1
View File
@@ -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<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
shiftKey: event.shiftKey,
@@ -927,6 +934,84 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
}
};
const handleChordRegionDrop = async (e: React.DragEvent<HTMLDivElement>, 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 (
<div className="grid-container" ref={gridContainerRef} onMouseDownCapture={startLassoSelection}>
{/* Playhead */}
@@ -959,6 +1044,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
onOpenHybrid={onOpenHybrid}
allTracks={tracks}
onKGOneClipDrop={handleExternalDrop}
onChordRegionDrop={handleChordRegionDrop}
previewRegionStyles={previewRegionStyles}
setPreviewRegionStyles={setPreviewRegionStyles}
previewRegionContentStyles={previewRegionContentStyles}
+1
View File
@@ -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';
@@ -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);
});
});
@@ -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;
}
}
+130
View File
@@ -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,
},
};
}
+90
View File
@@ -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');
});
});