880 lines
34 KiB
TypeScript
880 lines
34 KiB
TypeScript
import React, { useEffect, useState, useRef } from 'react';
|
|
import { KGTrack } from '../../core/track/KGTrack';
|
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
|
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
|
import RegionItem from './RegionItem';
|
|
import TrackAutomationLane from './TrackAutomationLane';
|
|
import type { RegionClickOptions, RegionPreviewContentStyle, RegionUI, ResizeAction } from '../interfaces';
|
|
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';
|
|
import { TrackType } from '../../core/track/KGTrack';
|
|
import { buildRegionSurfaceColors, resolveRegionColor } from '../../util/regionColor';
|
|
|
|
interface RegionResizePreviewBaseline {
|
|
regionId: string;
|
|
originalBarNumber: number;
|
|
originalLength: number;
|
|
originalLeft: number;
|
|
originalWidth: number;
|
|
originalContentWidth: number;
|
|
}
|
|
|
|
interface RegionDragPreviewBaseline {
|
|
regionId: string;
|
|
originalBarNumber: number;
|
|
originalTrackIndex: number;
|
|
originalLeft: number;
|
|
originalWidth: number;
|
|
}
|
|
|
|
interface TrackGridItemProps {
|
|
track: KGTrack;
|
|
index: number;
|
|
isDragging: boolean;
|
|
isDragOver: boolean;
|
|
regions: RegionUI[];
|
|
maxBars: number;
|
|
selectedRegionId: string | null;
|
|
gridContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>, index: number) => void;
|
|
onClick?: (e: React.MouseEvent<HTMLDivElement>, index: number) => void;
|
|
onRegionResize?: (regionId: string, newBarNumber: number, newLength: number) => void;
|
|
onRegionResizeEnd?: (regionId: string, finalBarNumber: number, finalLength: number) => void;
|
|
onRegionDrag?: (regionId: string, newBarNumber: number, newTrackIndex: number) => void;
|
|
onRegionDragEnd?: (regionId: string, finalBarNumber: number, finalTrackIndex: number) => void;
|
|
onRegionFineMoveEnd?: (regionId: string, deltaInBars: number) => void;
|
|
onRegionClick?: (regionId: string, options: RegionClickOptions) => void;
|
|
onOpenPianoRoll?: (regionId: string) => void;
|
|
onOpenWaveform?: (regionId: string) => void;
|
|
onOpenSpectrogram?: (regionId: string) => void;
|
|
showHybridButtonForAudio?: boolean;
|
|
showHybridButtonForMidi?: boolean;
|
|
onOpenHybrid?: (regionId: string) => void;
|
|
allTracks?: KGTrack[]; // Added to access all tracks for drag operations
|
|
onKGOneClipDrop?: (e: React.DragEvent<HTMLDivElement>, trackIndex: number) => void;
|
|
onAudioFileDrop?: (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>;
|
|
setPreviewRegionContentStyles?: React.Dispatch<React.SetStateAction<Record<string, RegionPreviewContentStyle>>>;
|
|
}
|
|
|
|
const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
|
track,
|
|
index,
|
|
isDragging,
|
|
isDragOver,
|
|
regions,
|
|
maxBars,
|
|
selectedRegionId,
|
|
gridContainerRef,
|
|
onDoubleClick,
|
|
onClick,
|
|
onRegionResize,
|
|
onRegionResizeEnd,
|
|
onRegionDrag,
|
|
onRegionDragEnd,
|
|
onRegionFineMoveEnd,
|
|
onRegionClick,
|
|
onOpenPianoRoll,
|
|
onOpenWaveform,
|
|
onOpenSpectrogram,
|
|
showHybridButtonForAudio,
|
|
showHybridButtonForMidi,
|
|
onOpenHybrid,
|
|
allTracks,
|
|
onKGOneClipDrop,
|
|
onAudioFileDrop,
|
|
onChordRegionDrop,
|
|
previewRegionStyles,
|
|
setPreviewRegionStyles,
|
|
previewRegionContentStyles,
|
|
setPreviewRegionContentStyles,
|
|
}) => {
|
|
const selectedRegionIds = useProjectStore(state => state.selectedRegionIds);
|
|
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
|
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
|
|
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
|
|
const audioWaveformRedrawVersion = useProjectStore(state => state.audioWaveformRedrawVersion);
|
|
const recordingMode = useProjectStore(state => state.recordingMode);
|
|
const recordingTargetTrackIndex = useProjectStore(state => state.recordingTargetTrackIndex);
|
|
const recordingCommitStartBeatAbsolute = useProjectStore(state => state.recordingCommitStartBeatAbsolute);
|
|
const recordingAudioPreviewCurrentBeat = useProjectStore(state => state.recordingAudioPreviewCurrentBeat);
|
|
const recordingAudioPreviewPeaks = useProjectStore(state => state.recordingAudioPreviewPeaks);
|
|
const recordingAudioPreviewFileName = useProjectStore(state => state.recordingAudioPreviewFileName);
|
|
const storeTimeSignature = useProjectStore(state => state.timeSignature);
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
|
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
|
|
const [localTempRegionStyles, setLocalTempRegionStyles] = useState<Record<string, React.CSSProperties>>({});
|
|
const [localPreviewRegionContentStyles, setLocalPreviewRegionContentStyles] = useState<Record<string, RegionPreviewContentStyle>>({});
|
|
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
|
|
|
// Refs for resize operations
|
|
const mouseMoved = useRef(false);
|
|
const currentResizeWidth = useRef<number | null>(null);
|
|
const currentResizeLeft = useRef<number | null>(null);
|
|
const currentResizeRegion = useRef<RegionUI | null>(null);
|
|
const initialBarNumberRef = useRef<number | null>(null);
|
|
const initialLengthRef = useRef<number | null>(null);
|
|
const resizePreviewBaselinesRef = useRef<RegionResizePreviewBaseline[]>([]);
|
|
const resizePreviewRegionIdsRef = useRef<string[]>([]);
|
|
|
|
// Refs for drag operations
|
|
const currentDragLeft = useRef<number | null>(null);
|
|
const currentDragTop = useRef<number | null>(null);
|
|
const currentDragRegion = useRef<RegionUI | null>(null);
|
|
const dragPreviewBaselinesRef = useRef<RegionDragPreviewBaseline[]>([]);
|
|
const dragPreviewRegionIdsRef = useRef<string[]>([]);
|
|
const trackElementRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const isBulkRegionEdit = (regionId: string) => selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId);
|
|
const tempRegionStyles = previewRegionStyles ?? localTempRegionStyles;
|
|
const setTempRegionStyles = setPreviewRegionStyles ?? setLocalTempRegionStyles;
|
|
const tempPreviewRegionContentStyles = previewRegionContentStyles ?? localPreviewRegionContentStyles;
|
|
const setTempPreviewRegionContentStyles = setPreviewRegionContentStyles ?? setLocalPreviewRegionContentStyles;
|
|
|
|
const getPreviewRegionIds = (regionId: string) => (
|
|
selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId)
|
|
? selectedRegionIds
|
|
: [regionId]
|
|
);
|
|
|
|
const clearTempRegionStyles = (regionIds?: string[]) => {
|
|
if (!regionIds || regionIds.length === 0) {
|
|
setTempRegionStyles({});
|
|
return;
|
|
}
|
|
|
|
setTempRegionStyles(prev => {
|
|
const updated = { ...prev };
|
|
regionIds.forEach(id => {
|
|
delete updated[id];
|
|
});
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
const clearTempPreviewRegionContentStyles = (regionIds?: string[]) => {
|
|
if (!regionIds || regionIds.length === 0) {
|
|
setTempPreviewRegionContentStyles({});
|
|
return;
|
|
}
|
|
|
|
setTempPreviewRegionContentStyles(prev => {
|
|
const updated = { ...prev };
|
|
regionIds.forEach(id => {
|
|
delete updated[id];
|
|
});
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
const getMeasuredRegionContentWidth = (regionId: string, fallbackWidth: number) => {
|
|
const regionElement = Array.from(document.querySelectorAll<HTMLElement>('[data-region-id]'))
|
|
.find(element => element.getAttribute('data-region-id') === regionId);
|
|
const regionContentElement = regionElement?.querySelector<HTMLElement>('.region-content');
|
|
const measuredWidth = regionContentElement?.getBoundingClientRect().width;
|
|
|
|
if (!measuredWidth || Number.isNaN(measuredWidth)) {
|
|
return fallbackWidth;
|
|
}
|
|
|
|
return measuredWidth;
|
|
};
|
|
|
|
// Update container width when the grid container changes size
|
|
useEffect(() => {
|
|
if (!gridContainerRef.current) return;
|
|
|
|
setContainerWidth(gridContainerRef.current.clientWidth);
|
|
|
|
const resizeObserver = new ResizeObserver(entries => {
|
|
for (const entry of entries) {
|
|
setContainerWidth(entry.contentRect.width);
|
|
}
|
|
});
|
|
|
|
resizeObserver.observe(gridContainerRef.current);
|
|
|
|
return () => {
|
|
if (gridContainerRef.current) {
|
|
resizeObserver.unobserve(gridContainerRef.current);
|
|
}
|
|
};
|
|
}, [gridContainerRef]);
|
|
|
|
// Track tool state for cursor feedback.
|
|
useEffect(() => {
|
|
const syncCursorState = (event?: KeyboardEvent | MouseEvent) => {
|
|
const isPencilMode = KGMainContentState.instance().getActiveTool() === 'pencil';
|
|
const hasModifierPressed = event ? isModifierKeyPressed(event) : false;
|
|
setIsModifierPressed(isPencilMode || hasModifierPressed);
|
|
};
|
|
const syncCursorStateFromFocus = () => {
|
|
setIsModifierPressed(KGMainContentState.instance().getActiveTool() === 'pencil');
|
|
};
|
|
|
|
// Add global event listeners
|
|
window.addEventListener('keydown', syncCursorState);
|
|
window.addEventListener('keyup', syncCursorState);
|
|
window.addEventListener('focus', syncCursorStateFromFocus);
|
|
syncCursorStateFromFocus();
|
|
|
|
// Cleanup listeners on unmount
|
|
return () => {
|
|
window.removeEventListener('keydown', syncCursorState);
|
|
window.removeEventListener('keyup', syncCursorState);
|
|
window.removeEventListener('focus', syncCursorStateFromFocus);
|
|
};
|
|
}, []);
|
|
|
|
// Calculate region position and style
|
|
const getRegionStyle = (region: RegionUI) => {
|
|
// Check if there's a temporary style for this region during resize or drag
|
|
if (tempRegionStyles[region.id]) {
|
|
return tempRegionStyles[region.id];
|
|
}
|
|
|
|
// Calculate the width of each bar
|
|
const barWidth = containerWidth / maxBars;
|
|
|
|
// Calculate left position (0-indexed bar number)
|
|
const left = (region.barNumber - 1) * barWidth;
|
|
|
|
// Calculate width based on region length
|
|
const width = region.length * barWidth;
|
|
|
|
return {
|
|
left: `${left}px`,
|
|
width: `${width}px`,
|
|
position: 'absolute' as const, // Fixed: Use const assertion
|
|
};
|
|
};
|
|
|
|
// Handle region resize start
|
|
const handleRegionResizeStart = (regionId: string, resizeAction: ResizeAction, initialX: number) => {
|
|
// Disable resizing in pencil mode
|
|
if (KGMainContentState.instance().getActiveTool() === 'pencil') {
|
|
return;
|
|
}
|
|
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`RESIZE START: regionId=${regionId}, action=${resizeAction}`);
|
|
}
|
|
|
|
setResizingRegion(regionId);
|
|
|
|
// Reset the mouse moved flag
|
|
mouseMoved.current = false;
|
|
|
|
// Find the region being resized
|
|
const region = regions.find(r => r.id === regionId);
|
|
if (!region) return;
|
|
|
|
// Store the region for reference
|
|
currentResizeRegion.current = region;
|
|
initialBarNumberRef.current = region.barNumber;
|
|
initialLengthRef.current = region.length;
|
|
|
|
// Calculate the width of each bar
|
|
const barWidth = containerWidth / maxBars;
|
|
|
|
// Store the initial width and left position
|
|
currentResizeWidth.current = region.length * barWidth;
|
|
currentResizeLeft.current = (region.barNumber - 1) * barWidth;
|
|
|
|
const previewRegionIds = getPreviewRegionIds(regionId);
|
|
resizePreviewBaselinesRef.current = previewRegionIds
|
|
.map(id => regions.find(candidate => candidate.id === id))
|
|
.filter((candidate): candidate is RegionUI => candidate !== undefined)
|
|
.map(candidate => ({
|
|
regionId: candidate.id,
|
|
originalBarNumber: candidate.barNumber,
|
|
originalLength: candidate.length,
|
|
originalLeft: (candidate.barNumber - 1) * barWidth,
|
|
originalWidth: candidate.length * barWidth,
|
|
originalContentWidth: getMeasuredRegionContentWidth(candidate.id, candidate.length * barWidth),
|
|
}));
|
|
resizePreviewRegionIdsRef.current = resizePreviewBaselinesRef.current.map(baseline => baseline.regionId);
|
|
|
|
setTempRegionStyles(prev => ({
|
|
...prev,
|
|
...Object.fromEntries(resizePreviewBaselinesRef.current.map(baseline => [
|
|
baseline.regionId,
|
|
{
|
|
left: `${baseline.originalLeft}px`,
|
|
width: `${baseline.originalWidth}px`,
|
|
position: 'absolute' as const,
|
|
},
|
|
])),
|
|
}));
|
|
|
|
setTempPreviewRegionContentStyles(prev => ({
|
|
...prev,
|
|
...Object.fromEntries(resizePreviewBaselinesRef.current.map(baseline => [
|
|
baseline.regionId,
|
|
{
|
|
left: '0px',
|
|
width: `${baseline.originalContentWidth}px`,
|
|
},
|
|
])),
|
|
}));
|
|
};
|
|
|
|
// Handle region resize
|
|
const handleRegionResize = (regionId: string, resizeAction: ResizeAction, deltaX: number) => {
|
|
// Set the mouse moved flag to true
|
|
mouseMoved.current = true;
|
|
|
|
// Find the region being resized
|
|
const region = regions.find(r => r.id === regionId);
|
|
if (!region) return;
|
|
|
|
// Calculate the width of each bar
|
|
const barWidth = containerWidth / maxBars;
|
|
|
|
// Get initial values
|
|
const originalWidth = initialLengthRef.current! * barWidth;
|
|
const originalLeft = (initialBarNumberRef.current! - 1) * barWidth;
|
|
|
|
let newLeft = originalLeft;
|
|
let newWidth = originalWidth;
|
|
|
|
if (resizeAction === 'end') {
|
|
// End resize: adjust width only
|
|
newWidth = Math.max(barWidth * REGION_CONSTANTS.MIN_REGION_LENGTH, originalWidth + deltaX);
|
|
} else if (resizeAction === 'start') {
|
|
// Start resize: adjust both left position and width
|
|
// Calculate maximum delta to prevent negative width
|
|
const maxDelta = originalWidth - barWidth * REGION_CONSTANTS.MIN_REGION_LENGTH;
|
|
const clampedDeltaX = Math.min(maxDelta, deltaX);
|
|
|
|
// Adjust left position and width
|
|
newLeft = originalLeft + clampedDeltaX;
|
|
newWidth = originalWidth - clampedDeltaX;
|
|
}
|
|
|
|
// Store the current values in refs
|
|
currentResizeWidth.current = newWidth;
|
|
currentResizeLeft.current = newLeft;
|
|
|
|
// Calculate the new bar number and length (not rounded yet, for smooth resizing)
|
|
const newBarNumber = (newLeft / barWidth) + 1;
|
|
const newLength = newWidth / barWidth;
|
|
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`RESIZE: regionId=${regionId}, action=${resizeAction}, deltaX=${deltaX}, newBarNumber=${newBarNumber}, newLength=${newLength}`);
|
|
}
|
|
|
|
const leftDelta = newLeft - originalLeft;
|
|
const widthDelta = newWidth - originalWidth;
|
|
const previewBaselines = resizePreviewBaselinesRef.current.length > 0
|
|
? resizePreviewBaselinesRef.current
|
|
: [{
|
|
regionId,
|
|
originalBarNumber: region.barNumber,
|
|
originalLength: region.length,
|
|
originalLeft,
|
|
originalWidth,
|
|
originalContentWidth: getMeasuredRegionContentWidth(regionId, originalWidth),
|
|
}];
|
|
|
|
setTempRegionStyles(prev => ({
|
|
...prev,
|
|
...Object.fromEntries(previewBaselines.map(baseline => [
|
|
baseline.regionId,
|
|
{
|
|
left: `${resizeAction === 'start' ? baseline.originalLeft + leftDelta : baseline.originalLeft}px`,
|
|
width: `${baseline.originalWidth + widthDelta}px`,
|
|
position: 'absolute' as const,
|
|
},
|
|
])),
|
|
}));
|
|
|
|
setTempPreviewRegionContentStyles(prev => ({
|
|
...prev,
|
|
...Object.fromEntries(previewBaselines.map(baseline => [
|
|
baseline.regionId,
|
|
{
|
|
left: `${resizeAction === 'start' ? -(newLeft - originalLeft) : 0}px`,
|
|
width: `${baseline.originalContentWidth}px`,
|
|
},
|
|
])),
|
|
}));
|
|
|
|
// Notify parent about resize
|
|
if (onRegionResize) {
|
|
onRegionResize(regionId, newBarNumber, newLength);
|
|
}
|
|
};
|
|
|
|
// Handle region resize end
|
|
const handleRegionResizeEnd = (regionId: string, resizeAction: ResizeAction) => {
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`RESIZE END: regionId=${regionId}, action=${resizeAction}, mouseMoved=${mouseMoved.current}`);
|
|
console.log('Current resize width from ref:', currentResizeWidth.current);
|
|
console.log('Current resize left from ref:', currentResizeLeft.current);
|
|
}
|
|
|
|
// Find the region being resized
|
|
const region = regions.find(r => r.id === regionId) || currentResizeRegion.current;
|
|
if (!region) {
|
|
console.error(`Region not found: ${regionId}`);
|
|
return;
|
|
}
|
|
|
|
// Calculate the width of each bar
|
|
const barWidth = containerWidth / maxBars;
|
|
|
|
let newBarNumber = region.barNumber; // Default to current bar number
|
|
let newLength = region.length; // Default to current length
|
|
|
|
// If the mouse was moved and we have current values, calculate the new values
|
|
if (mouseMoved.current && currentResizeWidth.current !== null && currentResizeLeft.current !== null) {
|
|
const snap = KGMainContentState.instance().isSnappingEnabled();
|
|
|
|
if (resizeAction === 'end') {
|
|
// End resize: snap length to nearest bar, or use raw value
|
|
const rawLength = currentResizeWidth.current / barWidth;
|
|
newLength = Math.max(REGION_CONSTANTS.MIN_REGION_LENGTH, snap ? Math.round(rawLength) : rawLength);
|
|
} else if (resizeAction === 'start') {
|
|
// Start resize: snap bar number, or use raw value
|
|
const rawBarNumber = currentResizeLeft.current / barWidth + 1;
|
|
newBarNumber = Math.max(1, snap ? Math.round(rawBarNumber) : rawBarNumber);
|
|
|
|
// Calculate the difference from the initial position
|
|
const barDiff = initialBarNumberRef.current! - newBarNumber;
|
|
|
|
// Adjust length to maintain the end position
|
|
newLength = initialLengthRef.current! + barDiff;
|
|
|
|
// Ensure minimum length
|
|
if (newLength < REGION_CONSTANTS.MIN_REGION_LENGTH) {
|
|
newLength = REGION_CONSTANTS.MIN_REGION_LENGTH;
|
|
newBarNumber = initialBarNumberRef.current! + initialLengthRef.current! - REGION_CONSTANTS.MIN_REGION_LENGTH;
|
|
}
|
|
}
|
|
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`Calculated new values: barNumber=${newBarNumber}, length=${newLength}`);
|
|
}
|
|
} else if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`Using existing values: barNumber=${newBarNumber}, length=${newLength}`);
|
|
}
|
|
|
|
// Clear resizing state
|
|
setResizingRegion(null);
|
|
clearTempRegionStyles(resizePreviewRegionIdsRef.current);
|
|
clearTempPreviewRegionContentStyles(resizePreviewRegionIdsRef.current);
|
|
currentResizeWidth.current = null;
|
|
currentResizeLeft.current = null;
|
|
currentResizeRegion.current = null;
|
|
initialBarNumberRef.current = null;
|
|
initialLengthRef.current = null;
|
|
resizePreviewBaselinesRef.current = [];
|
|
resizePreviewRegionIdsRef.current = [];
|
|
|
|
// Notify parent about resize end with rounded values
|
|
if (onRegionResizeEnd) {
|
|
onRegionResizeEnd(regionId, newBarNumber, newLength);
|
|
}
|
|
};
|
|
|
|
// Handle region drag start
|
|
const handleRegionDragStart = (regionId: string, initialX: number, initialY: number) => {
|
|
// Disable dragging in pencil mode
|
|
if (KGMainContentState.instance().getActiveTool() === 'pencil') {
|
|
return;
|
|
}
|
|
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`DRAG START: regionId=${regionId}`);
|
|
}
|
|
|
|
setDraggingRegion(regionId);
|
|
|
|
// Reset the mouse moved flag
|
|
mouseMoved.current = false;
|
|
|
|
// Find the region being dragged
|
|
const region = regions.find(r => r.id === regionId);
|
|
if (!region) return;
|
|
|
|
// Store the region for reference
|
|
currentDragRegion.current = region;
|
|
|
|
// Calculate the width of each bar
|
|
const barWidth = containerWidth / maxBars;
|
|
|
|
// Calculate the left position
|
|
const left = (region.barNumber - 1) * barWidth;
|
|
const width = region.length * barWidth;
|
|
|
|
// Store the initial position
|
|
currentDragLeft.current = left;
|
|
currentDragTop.current = 0; // Initially at the top of the current track
|
|
|
|
const previewRegionIds = getPreviewRegionIds(regionId);
|
|
dragPreviewBaselinesRef.current = previewRegionIds
|
|
.map(id => regions.find(candidate => candidate.id === id))
|
|
.filter((candidate): candidate is RegionUI => candidate !== undefined)
|
|
.map(candidate => ({
|
|
regionId: candidate.id,
|
|
originalBarNumber: candidate.barNumber,
|
|
originalTrackIndex: candidate.trackIndex,
|
|
originalLeft: (candidate.barNumber - 1) * barWidth,
|
|
originalWidth: candidate.length * barWidth,
|
|
}));
|
|
dragPreviewRegionIdsRef.current = dragPreviewBaselinesRef.current.map(baseline => baseline.regionId);
|
|
|
|
setTempRegionStyles(prev => ({
|
|
...prev,
|
|
...Object.fromEntries(dragPreviewBaselinesRef.current.map(baseline => [
|
|
baseline.regionId,
|
|
{
|
|
left: `${baseline.originalLeft}px`,
|
|
width: `${baseline.originalWidth}px`,
|
|
position: 'absolute' as const,
|
|
zIndex: 100,
|
|
transform: 'translateY(0px)',
|
|
},
|
|
])),
|
|
}));
|
|
};
|
|
|
|
// Handle region drag
|
|
const handleRegionDrag = (regionId: string, deltaX: number, deltaY: number) => {
|
|
// Set the mouse moved flag to true
|
|
mouseMoved.current = true;
|
|
|
|
// Find the region being dragged
|
|
const region = regions.find(r => r.id === regionId);
|
|
if (!region) return;
|
|
|
|
// Calculate the width of each bar
|
|
const barWidth = containerWidth / maxBars;
|
|
|
|
// Get the initial left position
|
|
const initialLeft = (region.barNumber - 1) * barWidth;
|
|
|
|
const isBulkEdit = isBulkRegionEdit(regionId);
|
|
const appliedDeltaY = isBulkEdit ? 0 : deltaY;
|
|
|
|
// Calculate new left position
|
|
const newLeft = initialLeft + deltaX;
|
|
|
|
// Calculate the new bar number (not rounded yet, for smooth dragging)
|
|
const newBarNumber = (newLeft / barWidth) + 1;
|
|
|
|
// Store the current drag position for use in handleRegionDragEnd
|
|
currentDragLeft.current = newLeft;
|
|
currentDragTop.current = appliedDeltaY;
|
|
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`DRAG: regionId=${regionId}, deltaX=${deltaX}, deltaY=${deltaY}, newBarNumber=${newBarNumber}`);
|
|
}
|
|
|
|
const leftDelta = newLeft - initialLeft;
|
|
const previewBaselines = dragPreviewBaselinesRef.current.length > 0
|
|
? dragPreviewBaselinesRef.current
|
|
: [{
|
|
regionId,
|
|
originalBarNumber: region.barNumber,
|
|
originalTrackIndex: region.trackIndex,
|
|
originalLeft: initialLeft,
|
|
originalWidth: region.length * barWidth,
|
|
}];
|
|
|
|
setTempRegionStyles(prev => ({
|
|
...prev,
|
|
...Object.fromEntries(previewBaselines.map(baseline => [
|
|
baseline.regionId,
|
|
{
|
|
left: `${baseline.originalLeft + leftDelta}px`,
|
|
width: `${baseline.originalWidth}px`,
|
|
position: 'absolute' as const,
|
|
zIndex: 100,
|
|
transform: `translateY(${appliedDeltaY}px)`,
|
|
},
|
|
])),
|
|
}));
|
|
|
|
// We'll calculate the track index on drag end, but still notify parent about the drag
|
|
if (onRegionDrag) {
|
|
onRegionDrag(regionId, newBarNumber, region.trackIndex);
|
|
}
|
|
};
|
|
|
|
// Handle region drag end
|
|
const handleRegionDragEnd = (regionId: string) => {
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`DRAG END: regionId=${regionId}, mouseMoved=${mouseMoved.current}`);
|
|
}
|
|
|
|
// Find the region being dragged
|
|
const region = regions.find(r => r.id === regionId) || currentDragRegion.current;
|
|
if (!region) {
|
|
console.error(`Region not found: ${regionId}`);
|
|
return;
|
|
}
|
|
|
|
// Calculate the width of each bar
|
|
const barWidth = containerWidth / maxBars;
|
|
|
|
// Default to current position
|
|
let finalBarNumber = region.barNumber;
|
|
let finalTrackIndex = region.trackIndex;
|
|
|
|
// If the mouse was moved, calculate the final position
|
|
if (mouseMoved.current && currentDragLeft.current !== null && currentDragTop.current !== null) {
|
|
const isBulkEdit = isBulkRegionEdit(regionId);
|
|
// Calculate the new bar number; snap to nearest integer when snapping is on
|
|
const snap = KGMainContentState.instance().isSnappingEnabled();
|
|
const rawBarNumber = (currentDragLeft.current / barWidth) + 1;
|
|
finalBarNumber = Math.max(1, snap ? Math.round(rawBarNumber) : rawBarNumber);
|
|
|
|
// Calculate the closest track based on vertical position
|
|
if (!isBulkEdit && allTracks && allTracks.length > 0 && gridContainerRef.current) {
|
|
const trackHeight = gridContainerRef.current.clientHeight / allTracks.length;
|
|
|
|
// Calculate the absolute vertical position
|
|
const originTrackTop = region.trackIndex * trackHeight;
|
|
const absoluteY = originTrackTop + currentDragTop.current;
|
|
|
|
// Find the closest track index
|
|
const closestTrackIndex = Math.max(0, Math.min(
|
|
allTracks.length - 1,
|
|
Math.round(absoluteY / trackHeight)
|
|
));
|
|
|
|
finalTrackIndex = closestTrackIndex;
|
|
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`Calculated closest track: ${finalTrackIndex} (from Y=${currentDragTop.current}, absoluteY=${absoluteY}, trackHeight=${trackHeight})`);
|
|
if (finalTrackIndex !== region.trackIndex) {
|
|
console.log(`Track change: from trackIndex=${region.trackIndex} (trackId=${region.trackId}) to trackIndex=${finalTrackIndex} (trackId=${allTracks[finalTrackIndex].getId()})`);
|
|
}
|
|
}
|
|
} else {
|
|
finalTrackIndex = region.trackIndex;
|
|
}
|
|
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`Final position: barNumber=${finalBarNumber}, trackIndex=${finalTrackIndex}`);
|
|
}
|
|
}
|
|
|
|
// Clear dragging state
|
|
setDraggingRegion(null);
|
|
clearTempRegionStyles(dragPreviewRegionIdsRef.current);
|
|
clearTempPreviewRegionContentStyles(dragPreviewRegionIdsRef.current);
|
|
currentDragLeft.current = null;
|
|
currentDragTop.current = null;
|
|
currentDragRegion.current = null;
|
|
dragPreviewBaselinesRef.current = [];
|
|
dragPreviewRegionIdsRef.current = [];
|
|
|
|
if (finalBarNumber === region.barNumber && finalTrackIndex === region.trackIndex) {
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`Skipping no-op drag update for region ${regionId}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Notify parent about drag end with final values
|
|
if (onRegionDragEnd) {
|
|
onRegionDragEnd(regionId, finalBarNumber, finalTrackIndex);
|
|
}
|
|
};
|
|
|
|
// Handle fine-move end — convert raw pixel delta to delta in bars and pass up
|
|
const handleRegionFineMoveEnd = (regionId: string, rawPixelDelta: number) => {
|
|
const barWidth = containerWidth / maxBars;
|
|
if (barWidth <= 0) return;
|
|
const deltaInBars = (rawPixelDelta * REGION_CONSTANTS.FINE_MOVE_SPEED_RATIO) / barWidth;
|
|
onRegionFineMoveEnd?.(regionId, deltaInBars);
|
|
};
|
|
|
|
// Handle region click
|
|
const handleRegionClick = (regionId: string, options: RegionClickOptions) => {
|
|
if (DEBUG_MODE.TRACK_GRID_ITEM) {
|
|
console.log(`Region clicked: ${regionId}`);
|
|
}
|
|
|
|
if (onRegionClick) {
|
|
onRegionClick(regionId, options);
|
|
}
|
|
};
|
|
|
|
// Filter regions for this track
|
|
const trackRegions = regions.filter(region => region.trackIndex === index);
|
|
const isAutomationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
|
|
const shouldRenderRecordingPreview = recordingMode === 'audio'
|
|
&& recordingTargetTrackIndex === index
|
|
&& recordingAudioPreviewCurrentBeat >= recordingCommitStartBeatAbsolute;
|
|
const previewRegionStyle = shouldRenderRecordingPreview
|
|
? {
|
|
left: `${(recordingCommitStartBeatAbsolute / storeTimeSignature.numerator) * (containerWidth / maxBars)}px`,
|
|
width: `${Math.max(0, ((recordingAudioPreviewCurrentBeat - recordingCommitStartBeatAbsolute) / storeTimeSignature.numerator) * (containerWidth / maxBars))}px`,
|
|
position: 'absolute' as const,
|
|
}
|
|
: null;
|
|
|
|
return (
|
|
<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);
|
|
}
|
|
}}
|
|
onClick={(e) => {
|
|
if (!isAutomationActive) {
|
|
onClick?.(e, index);
|
|
}
|
|
}}
|
|
ref={trackElementRef}
|
|
onDragOver={(e) => {
|
|
const dataTypes = Array.from(e.dataTransfer.types);
|
|
const hasLocalFiles = (e.dataTransfer.files?.length ?? 0) > 0 || dataTypes.includes('Files');
|
|
|
|
if (dataTypes.includes('application/kgone-clip') || dataTypes.includes(CHORD_REGION_IMPORT_MIME_TYPE)) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
return;
|
|
}
|
|
|
|
if (hasLocalFiles) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = track.getType() === TrackType.Wave ? 'copy' : 'none';
|
|
}
|
|
}}
|
|
onDrop={(e) => {
|
|
const dataTypes = Array.from(e.dataTransfer.types);
|
|
const hasLocalFiles = (e.dataTransfer.files?.length ?? 0) > 0 || dataTypes.includes('Files');
|
|
|
|
if (dataTypes.includes('application/kgone-clip')) {
|
|
e.preventDefault();
|
|
onKGOneClipDrop?.(e, index);
|
|
return;
|
|
}
|
|
|
|
if (dataTypes.includes(CHORD_REGION_IMPORT_MIME_TYPE)) {
|
|
e.preventDefault();
|
|
onChordRegionDrop?.(e, index);
|
|
return;
|
|
}
|
|
|
|
if (hasLocalFiles) {
|
|
e.preventDefault();
|
|
onAudioFileDrop?.(e, index);
|
|
}
|
|
}}
|
|
>
|
|
{/* Render regions for this track */}
|
|
{trackRegions.map(region => {
|
|
// Find the corresponding region in the track
|
|
const coreRegion = track.getRegions().find(r => r.getId() === region.id);
|
|
const midiRegion = coreRegion?.getCurrentType() === 'KGMidiRegion' ? coreRegion as unknown as KGMidiRegion : undefined;
|
|
const audioRegion = coreRegion?.getCurrentType() === 'KGAudioRegion' ? coreRegion as unknown as KGAudioRegion : undefined;
|
|
|
|
// Get audio buffer for waveform rendering
|
|
let audioBuffer: AudioBuffer | undefined;
|
|
if (audioRegion) {
|
|
audioBuffer = KGAudioInterface.instance().getAudioBuffer(
|
|
track.getId().toString(),
|
|
audioRegion.getAudioFileId()
|
|
);
|
|
}
|
|
|
|
const effectiveColor = region.effectiveColor
|
|
?? resolveRegionColor(coreRegion?.getColor(), track.getColor(), !!audioRegion || !!region.isAudioRegion);
|
|
const surfaceColors = buildRegionSurfaceColors(effectiveColor);
|
|
|
|
return (
|
|
<RegionItem
|
|
key={region.id}
|
|
id={region.id}
|
|
name={region.name}
|
|
style={{
|
|
...getRegionStyle(region),
|
|
['--region-border-color' as string]: surfaceColors.borderColor,
|
|
['--region-header-bg' as string]: surfaceColors.headerColor,
|
|
['--region-header-hover-bg' as string]: surfaceColors.headerHoverColor,
|
|
['--region-content-bg' as string]: surfaceColors.contentColor,
|
|
}}
|
|
barNumber={region.barNumber}
|
|
length={region.length}
|
|
trackIndex={index}
|
|
onResizeStart={handleRegionResizeStart}
|
|
onResize={handleRegionResize}
|
|
onResizeEnd={handleRegionResizeEnd}
|
|
onDragStart={handleRegionDragStart}
|
|
onDrag={handleRegionDrag}
|
|
onDragEnd={handleRegionDragEnd}
|
|
onFineMoveEnd={handleRegionFineMoveEnd}
|
|
// Keep onClick for selection-only logic if needed by parent
|
|
onClick={handleRegionClick}
|
|
// New explicit pencil action — disabled for audio regions
|
|
onOpenPianoRoll={audioRegion ? undefined : (regionId) => {
|
|
if (onOpenPianoRoll) {
|
|
onOpenPianoRoll(regionId);
|
|
} else if (onRegionClick) {
|
|
// Fallback to legacy behavior
|
|
onRegionClick(regionId, { shiftKey: false, metaKey: false, ctrlKey: false });
|
|
}
|
|
}}
|
|
onOpenWaveform={audioRegion ? (regionId) => {
|
|
onOpenWaveform?.(regionId);
|
|
} : undefined}
|
|
onOpenSpectrogram={audioRegion ? (regionId) => {
|
|
onOpenSpectrogram?.(regionId);
|
|
} : undefined}
|
|
showHybridButton={audioRegion ? showHybridButtonForAudio : showHybridButtonForMidi}
|
|
onOpenHybrid={onOpenHybrid}
|
|
midiRegion={midiRegion}
|
|
audioRegion={audioRegion}
|
|
audioBuffer={audioBuffer}
|
|
previewContentStyle={tempPreviewRegionContentStyles[region.id]}
|
|
redrawVersion={audioWaveformRedrawVersion}
|
|
/>
|
|
);
|
|
})}
|
|
{shouldRenderRecordingPreview && previewRegionStyle && (
|
|
<RegionItem
|
|
key="audio-recording-preview"
|
|
id="audio-recording-preview"
|
|
name={recordingAudioPreviewFileName ?? 'Recording'}
|
|
style={previewRegionStyle}
|
|
barNumber={(recordingCommitStartBeatAbsolute / storeTimeSignature.numerator) + 1}
|
|
length={(recordingAudioPreviewCurrentBeat - recordingCommitStartBeatAbsolute) / storeTimeSignature.numerator}
|
|
trackIndex={index}
|
|
previewWaveformPeaks={recordingAudioPreviewPeaks}
|
|
isPreview
|
|
isAudioRegion
|
|
/>
|
|
)}
|
|
{isAutomationActive && activeTrackAutomationType && (
|
|
<TrackAutomationLane
|
|
track={track}
|
|
automationType={activeTrackAutomationType}
|
|
maxBars={maxBars}
|
|
timeSignature={storeTimeSignature}
|
|
redrawVersion={trackAutomationRedrawVersion}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TrackGridItem;
|