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}
|
||||
|
||||
Reference in New Issue
Block a user