feat: added global chord region to MIDI region conversion feature
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,13 +333,14 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={laneRef}
|
||||
className={`global-marker-lane global-chord-lane${popupRegionId ? ' popup-open' : ''}${isModifierPressed ? ' pencil-cursor' : ''}`}
|
||||
onMouseDown={handleLaneMouseDown}
|
||||
onDoubleClick={handleLaneDoubleClick}
|
||||
>
|
||||
{chordRegions.map(region => {
|
||||
<>
|
||||
<div
|
||||
ref={laneRef}
|
||||
className={`global-marker-lane global-chord-lane${popupRegionId ? ' popup-open' : ''}${isModifierPressed ? ' pencil-cursor' : ''}`}
|
||||
onMouseDown={handleLaneMouseDown}
|
||||
onDoubleClick={handleLaneDoubleClick}
|
||||
>
|
||||
{chordRegions.map(region => {
|
||||
const { startBeat, length } = getRenderedBeatState(region);
|
||||
const isSelected = selectedRegionIds.includes(region.getId());
|
||||
const left = startBeat * beatWidth;
|
||||
@@ -324,6 +393,9 @@ const GlobalChordLane: React.FC<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,
|
||||
@@ -368,8 +441,27 @@ const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
||||
</FloatingPopup>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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 { 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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 { 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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user