feat: added global chord region to MIDI region conversion feature
This commit is contained in:
@@ -366,6 +366,50 @@
|
|||||||
overflow: visible;
|
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 {
|
.global-key-signature-region .floating-popup {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: var(--track-grid-bar-width);
|
width: var(--track-grid-bar-width);
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import { useMainContentRegions } from '../hooks/useMainContentRegions';
|
|||||||
import { useMainContentGlobalTracks } from '../hooks/useMainContentGlobalTracks';
|
import { useMainContentGlobalTracks } from '../hooks/useMainContentGlobalTracks';
|
||||||
import { useMainContentViewport } from '../hooks/useMainContentViewport';
|
import { useMainContentViewport } from '../hooks/useMainContentViewport';
|
||||||
import MainContentGlobalTracksSection from './global-track/MainContentGlobalTracksSection';
|
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 {
|
interface MainContentProps {
|
||||||
onTrackClick?: () => void;
|
onTrackClick?: () => void;
|
||||||
@@ -244,6 +253,73 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setShowGlobalTracksMock(previous => !previous);
|
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 showHybridButtonForAudio = showPianoRoll && pianoRollMode === 'midi-edit';
|
||||||
const showHybridButtonForMidi = showPianoRoll && pianoRollMode === 'spectrogram';
|
const showHybridButtonForMidi = showPianoRoll && pianoRollMode === 'spectrogram';
|
||||||
const beatTicksPerBar = Math.max(0, timeSignature.numerator - 1);
|
const beatTicksPerBar = Math.max(0, timeSignature.numerator - 1);
|
||||||
@@ -307,6 +383,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
<MainContentGlobalTracksSection
|
<MainContentGlobalTracksSection
|
||||||
visible={showGlobalTracksMock}
|
visible={showGlobalTracksMock}
|
||||||
{...mainContentGlobalTracks.sectionProps}
|
{...mainContentGlobalTracks.sectionProps}
|
||||||
|
chordLaneProps={{
|
||||||
|
...mainContentGlobalTracks.sectionProps.chordLaneProps,
|
||||||
|
onDropChordRegionsToTrack: handleGlobalChordDropToTrack,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="main-content-body" onClick={mainContentRegions.handleEmptyMainContentClick}>
|
<div className="main-content-body" onClick={mainContentRegions.handleEmptyMainContentClick}>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ describe('GlobalChordLane', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.documentElement.style.setProperty('--track-grid-bar-width', '40');
|
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', () => {
|
it('opens the chord popup for an existing region', () => {
|
||||||
@@ -30,6 +34,7 @@ describe('GlobalChordLane', () => {
|
|||||||
onChangeChord={vi.fn()}
|
onChangeChord={vi.fn()}
|
||||||
onOpenPopup={onOpenPopup}
|
onOpenPopup={onOpenPopup}
|
||||||
onTabNavigate={vi.fn()}
|
onTabNavigate={vi.fn()}
|
||||||
|
onDropChordRegionsToTrack={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,6 +61,7 @@ describe('GlobalChordLane', () => {
|
|||||||
onChangeChord={vi.fn()}
|
onChangeChord={vi.fn()}
|
||||||
onOpenPopup={vi.fn()}
|
onOpenPopup={vi.fn()}
|
||||||
onTabNavigate={vi.fn()}
|
onTabNavigate={vi.fn()}
|
||||||
|
onDropChordRegionsToTrack={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,6 +104,7 @@ describe('GlobalChordLane', () => {
|
|||||||
onChangeChord={vi.fn()}
|
onChangeChord={vi.fn()}
|
||||||
onOpenPopup={vi.fn()}
|
onOpenPopup={vi.fn()}
|
||||||
onTabNavigate={vi.fn()}
|
onTabNavigate={vi.fn()}
|
||||||
|
onDropChordRegionsToTrack={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -140,6 +147,7 @@ describe('GlobalChordLane', () => {
|
|||||||
onChangeChord={vi.fn()}
|
onChangeChord={vi.fn()}
|
||||||
onOpenPopup={vi.fn()}
|
onOpenPopup={vi.fn()}
|
||||||
onTabNavigate={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 });
|
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 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 { KGChordRegion } from '../../core/region/KGChordRegion';
|
||||||
import type { RegionClickOptions } from '../interfaces';
|
import type { RegionClickOptions } from '../interfaces';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { TOOLBAR_CONSTANTS } from '../../constants';
|
import { TOOLBAR_CONSTANTS } from '../../constants';
|
||||||
import FloatingPopup from '../common/FloatingPopup';
|
import FloatingPopup from '../common/FloatingPopup';
|
||||||
import ChordPickerPopup from '../ChordPickerPopup';
|
import ChordPickerPopup from '../ChordPickerPopup';
|
||||||
|
import { TrackType } from '../../core/track/KGTrack';
|
||||||
|
|
||||||
interface GlobalChordLaneProps {
|
interface GlobalChordLaneProps {
|
||||||
chordRegions: KGChordRegion[];
|
chordRegions: KGChordRegion[];
|
||||||
@@ -21,9 +25,11 @@ interface GlobalChordLaneProps {
|
|||||||
onChangeChord: (regionId: string, symbol: string) => void;
|
onChangeChord: (regionId: string, symbol: string) => void;
|
||||||
onOpenPopup: (regionId: string) => void;
|
onOpenPopup: (regionId: string) => void;
|
||||||
onTabNavigate: (regionId: string, direction: 'forward' | 'backward') => void;
|
onTabNavigate: (regionId: string, direction: 'forward' | 'backward') => void;
|
||||||
|
onDropChordRegionsToTrack?: (draggedRegionId: string, trackIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResizeEdge = 'start' | 'end' | null;
|
type ResizeEdge = 'start' | 'end' | null;
|
||||||
|
type DragFeedbackMode = 'move' | 'import' | 'blocked';
|
||||||
|
|
||||||
const REGION_EDGE_HITBOX_PX = 8;
|
const REGION_EDGE_HITBOX_PX = 8;
|
||||||
const DRAG_THRESHOLD_PX = 4;
|
const DRAG_THRESHOLD_PX = 4;
|
||||||
@@ -48,16 +54,19 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
onChangeChord,
|
onChangeChord,
|
||||||
onOpenPopup,
|
onOpenPopup,
|
||||||
onTabNavigate,
|
onTabNavigate,
|
||||||
|
onDropChordRegionsToTrack,
|
||||||
}) => {
|
}) => {
|
||||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [previewBeats, setPreviewBeats] = useState<Record<string, { startBeat: number; length: number }>>({});
|
const [previewBeats, setPreviewBeats] = useState<Record<string, { startBeat: number; length: number }>>({});
|
||||||
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
|
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
|
||||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||||
|
const [dragFeedback, setDragFeedback] = useState<{ x: number; y: number; mode: DragFeedbackMode } | null>(null);
|
||||||
const suppressClickSelectionRef = useRef(false);
|
const suppressClickSelectionRef = useRef(false);
|
||||||
const interactionRef = useRef<{
|
const interactionRef = useRef<{
|
||||||
mode: 'drag' | 'resize' | null;
|
mode: 'drag' | 'resize' | null;
|
||||||
regionId: string;
|
regionId: string;
|
||||||
initialMouseX: number;
|
initialMouseX: number;
|
||||||
|
initialMouseY: number;
|
||||||
initialStartBeat: number;
|
initialStartBeat: number;
|
||||||
initialLength: number;
|
initialLength: number;
|
||||||
resizeEdge: ResizeEdge;
|
resizeEdge: ResizeEdge;
|
||||||
@@ -116,6 +125,32 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
return null;
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (isModifierKeyPressed(event)) {
|
if (isModifierKeyPressed(event)) {
|
||||||
@@ -146,7 +181,8 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
|
|
||||||
const interaction = interactionRef.current;
|
const interaction = interactionRef.current;
|
||||||
const deltaX = event.clientX - interaction.initialMouseX;
|
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;
|
interaction.moved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +195,28 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
length: interaction.initialLength,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +254,8 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
|
|
||||||
const interaction = interactionRef.current;
|
const interaction = interactionRef.current;
|
||||||
interactionRef.current = null;
|
interactionRef.current = null;
|
||||||
|
setDragFeedback(null);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
|
||||||
if (!interaction.moved) {
|
if (!interaction.moved) {
|
||||||
setPreviewBeats({});
|
setPreviewBeats({});
|
||||||
@@ -212,6 +272,12 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.mode === 'drag') {
|
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);
|
onMoveRegion(interaction.regionId, preview.startBeat);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,8 +295,10 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
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>) => {
|
const handleLaneMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0) {
|
if (event.button !== 0) {
|
||||||
@@ -265,6 +333,7 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
ref={laneRef}
|
ref={laneRef}
|
||||||
className={`global-marker-lane global-chord-lane${popupRegionId ? ' popup-open' : ''}${isModifierPressed ? ' pencil-cursor' : ''}`}
|
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) {
|
if (event.button !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ((event.target as HTMLElement).closest('.global-chord-drag-handle')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -333,6 +405,7 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
mode: resizeEdge ? 'resize' : 'drag',
|
mode: resizeEdge ? 'resize' : 'drag',
|
||||||
regionId: region.getId(),
|
regionId: region.getId(),
|
||||||
initialMouseX: event.clientX,
|
initialMouseX: event.clientX,
|
||||||
|
initialMouseY: event.clientY,
|
||||||
initialStartBeat: region.getStartFromBeat(),
|
initialStartBeat: region.getStartFromBeat(),
|
||||||
initialLength: region.getLength(),
|
initialLength: region.getLength(),
|
||||||
resizeEdge,
|
resizeEdge,
|
||||||
@@ -370,6 +443,25 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
|||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
|
import { CHORD_REGION_IMPORT_MIME_TYPE } from '../../util/chordRegionImportUtil';
|
||||||
|
|
||||||
interface RegionResizePreviewBaseline {
|
interface RegionResizePreviewBaseline {
|
||||||
regionId: string;
|
regionId: string;
|
||||||
@@ -52,6 +53,7 @@ interface TrackGridItemProps {
|
|||||||
onOpenHybrid?: (regionId: string) => void;
|
onOpenHybrid?: (regionId: string) => void;
|
||||||
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
||||||
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
||||||
|
onChordRegionDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
||||||
previewRegionStyles?: Record<string, React.CSSProperties>;
|
previewRegionStyles?: Record<string, React.CSSProperties>;
|
||||||
setPreviewRegionStyles?: React.Dispatch<React.SetStateAction<Record<string, React.CSSProperties>>>;
|
setPreviewRegionStyles?: React.Dispatch<React.SetStateAction<Record<string, React.CSSProperties>>>;
|
||||||
previewRegionContentStyles?: Record<string, RegionPreviewContentStyle>;
|
previewRegionContentStyles?: Record<string, RegionPreviewContentStyle>;
|
||||||
@@ -82,6 +84,7 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
onOpenHybrid,
|
onOpenHybrid,
|
||||||
allTracks,
|
allTracks,
|
||||||
onKGOneClipDrop,
|
onKGOneClipDrop,
|
||||||
|
onChordRegionDrop,
|
||||||
previewRegionStyles,
|
previewRegionStyles,
|
||||||
setPreviewRegionStyles,
|
setPreviewRegionStyles,
|
||||||
previewRegionContentStyles,
|
previewRegionContentStyles,
|
||||||
@@ -721,6 +724,8 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''} ${isAutomationActive ? 'automation-active' : ''}`}
|
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''} ${isAutomationActive ? 'automation-active' : ''}`}
|
||||||
data-test-id={`track-grid-${track.getId()}`}
|
data-test-id={`track-grid-${track.getId()}`}
|
||||||
|
data-track-index={index}
|
||||||
|
data-track-type={track.getType()}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
if (!isAutomationActive) {
|
if (!isAutomationActive) {
|
||||||
onDoubleClick(e, index);
|
onDoubleClick(e, index);
|
||||||
@@ -733,7 +738,10 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
ref={trackElementRef}
|
ref={trackElementRef}
|
||||||
onDragOver={(e) => {
|
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.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
}
|
}
|
||||||
@@ -742,6 +750,12 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|||||||
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
|
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onKGOneClipDrop?.(e, index);
|
onKGOneClipDrop?.(e, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.from(e.dataTransfer.types).includes(CHORD_REGION_IMPORT_MIME_TYPE)) {
|
||||||
|
e.preventDefault();
|
||||||
|
onChordRegionDrop?.(e, index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,9 +3,19 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { fireEvent, render } from '@testing-library/react';
|
import { fireEvent, render } from '@testing-library/react';
|
||||||
import TrackGridPanel from './TrackGridPanel';
|
import TrackGridPanel from './TrackGridPanel';
|
||||||
import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
|
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 executeCommandMock = vi.fn();
|
||||||
const getCreatedRegionMock = 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', () => ({
|
vi.mock('../../stores/projectStore', () => ({
|
||||||
useProjectStore: (selector?: (state: {
|
useProjectStore: (selector?: (state: {
|
||||||
@@ -32,15 +42,32 @@ vi.mock('../common', () => ({
|
|||||||
vi.mock('../../core/KGCore', () => ({
|
vi.mock('../../core/KGCore', () => ({
|
||||||
KGCore: {
|
KGCore: {
|
||||||
instance: () => ({
|
instance: () => ({
|
||||||
executeCommand: executeCommandMock,
|
executeCommand: (command: { execute?: () => void }) => {
|
||||||
|
command.execute?.();
|
||||||
|
executeCommandMock(command);
|
||||||
|
},
|
||||||
|
getCurrentProject: () => ({
|
||||||
|
getTracks: () => currentTracks,
|
||||||
|
getGlobalTracks: () => [{
|
||||||
|
getRegions: () => globalChordRegions,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../util/miscUtil', () => ({
|
vi.mock('../../util/dialogUtil', () => ({
|
||||||
generateNewRegionName: () => 'New Region',
|
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 () => {
|
vi.mock('../../core/commands', async () => {
|
||||||
const actual = await vi.importActual<typeof import('../../core/commands')>('../../core/commands');
|
const actual = await vi.importActual<typeof import('../../core/commands')>('../../core/commands');
|
||||||
return {
|
return {
|
||||||
@@ -88,6 +115,7 @@ describe('TrackGridPanel lasso selection', () => {
|
|||||||
const trackB = createMockMidiTrack({ id: 2, regions: [regionB] });
|
const trackB = createMockMidiTrack({ id: 2, regions: [regionB] });
|
||||||
trackA.setTrackIndex(0);
|
trackA.setTrackIndex(0);
|
||||||
trackB.setTrackIndex(1);
|
trackB.setTrackIndex(1);
|
||||||
|
currentTracks = [trackA, trackB];
|
||||||
|
|
||||||
const onRegionLassoSelection = vi.fn();
|
const onRegionLassoSelection = vi.fn();
|
||||||
const onRegionCreated = vi.fn();
|
const onRegionCreated = vi.fn();
|
||||||
@@ -131,6 +159,9 @@ describe('TrackGridPanel lasso selection', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
executeCommandMock.mockReset();
|
executeCommandMock.mockReset();
|
||||||
getCreatedRegionMock.mockReset();
|
getCreatedRegionMock.mockReset();
|
||||||
|
showAlertMock.mockReset();
|
||||||
|
globalChordRegions[0].setSymbol('C');
|
||||||
|
currentTracks = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects intersecting regions across multiple track rows', () => {
|
it('selects intersecting regions across multiple track rows', () => {
|
||||||
@@ -205,4 +236,111 @@ describe('TrackGridPanel lasso selection', () => {
|
|||||||
expect(onRegionCreated).toHaveBeenCalledTimes(1);
|
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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { RegionClickOptions, RegionPreviewContentStyle, RegionUI } from '..
|
|||||||
import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, REGION_CONSTANTS } from '../../constants';
|
import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, REGION_CONSTANTS } from '../../constants';
|
||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
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 { KGCore } from '../../core/KGCore';
|
||||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||||
@@ -19,6 +19,13 @@ import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
|
|||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { getAudioRegionDisplayLengthBeats } from '../../util/globalTrackUtil';
|
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 => ({
|
const getRegionClickOptions = (event: Pick<MouseEvent | React.MouseEvent, 'shiftKey' | 'metaKey' | 'ctrlKey'>): RegionClickOptions => ({
|
||||||
shiftKey: event.shiftKey,
|
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 (
|
return (
|
||||||
<div className="grid-container" ref={gridContainerRef} onMouseDownCapture={startLassoSelection}>
|
<div className="grid-container" ref={gridContainerRef} onMouseDownCapture={startLassoSelection}>
|
||||||
{/* Playhead */}
|
{/* Playhead */}
|
||||||
@@ -959,6 +1044,7 @@ const TrackGridPanel: React.FC<TrackGridPanelProps> = ({
|
|||||||
onOpenHybrid={onOpenHybrid}
|
onOpenHybrid={onOpenHybrid}
|
||||||
allTracks={tracks}
|
allTracks={tracks}
|
||||||
onKGOneClipDrop={handleExternalDrop}
|
onKGOneClipDrop={handleExternalDrop}
|
||||||
|
onChordRegionDrop={handleChordRegionDrop}
|
||||||
previewRegionStyles={previewRegionStyles}
|
previewRegionStyles={previewRegionStyles}
|
||||||
setPreviewRegionStyles={setPreviewRegionStyles}
|
setPreviewRegionStyles={setPreviewRegionStyles}
|
||||||
previewRegionContentStyles={previewRegionContentStyles}
|
previewRegionContentStyles={previewRegionContentStyles}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export { PasteRegionsCommand } from './region/PasteRegionsCommand';
|
|||||||
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
|
export { UpdateRegionCommand, type RegionUpdateProperties } from './region/UpdateRegionCommand';
|
||||||
export { ImportAudioCommand } from './region/ImportAudioCommand';
|
export { ImportAudioCommand } from './region/ImportAudioCommand';
|
||||||
export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
|
export { ImportMidiClipCommand } from './region/ImportMidiClipCommand';
|
||||||
|
export { ImportChordRegionsCommand } from './region/ImportChordRegionsCommand';
|
||||||
export { ImportStemsCommand } from './region/ImportStemsCommand';
|
export { ImportStemsCommand } from './region/ImportStemsCommand';
|
||||||
export type { StemImportEntry } from './region/ImportStemsCommand';
|
export type { StemImportEntry } from './region/ImportStemsCommand';
|
||||||
export { SplitRegionCommand } from './region/SplitRegionCommand';
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildChordSymbol, formatChordSymbolForDisplay, getChordMidiPitches, getChordPitchClasses, parseChordSymbol } from './chordUtil';
|
import { buildChordSymbol, formatChordSymbolForDisplay, getChordMidiPitches, getChordPitchClasses, parseChordSymbol } from './chordUtil';
|
||||||
|
import { buildChordRegionImportPlan, convertChordSymbolToMidiPitches } from './chordRegionImportUtil';
|
||||||
|
import { KGChordRegion } from '../core/region/KGChordRegion';
|
||||||
|
|
||||||
describe('chordUtil', () => {
|
describe('chordUtil', () => {
|
||||||
it('parses half-diminished chords into the popup descriptor shape', () => {
|
it('parses half-diminished chords into the popup descriptor shape', () => {
|
||||||
@@ -41,4 +43,92 @@ describe('chordUtil', () => {
|
|||||||
expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
|
expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
|
||||||
expect(formatChordSymbolForDisplay('Bbmaj7#11')).toBe('B♭maj7(♯11)');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user