feat: implemented track level automation

This commit is contained in:
Xiaohan-Tian
2026-05-08 15:44:07 -07:00
parent 6e5d9466f4
commit 31e02b4149
24 changed files with 1931 additions and 36 deletions
+49 -4
View File
@@ -16,6 +16,7 @@ import { useRegionOperations } from '../hooks/useRegionOperations';
import { regionDeleteManager } from '../util/regionDeleteUtil';
import { KGMainContentState } from '../core/state/KGMainContentState';
import { ChangeLoopSettingsCommand } from '../core/commands';
import { DeleteTrackAutomationPointsCommand } from '../core/commands';
interface MainContentProps {
onTrackClick?: () => void;
@@ -55,6 +56,11 @@ const MainContent: React.FC<MainContentProps> = ({
savedProjectName,
requestPianoRollScroll,
mainContentScrollRequest,
activeTrackAutomationTrackId,
activeTrackAutomationType,
selectedTrackAutomationPointIds,
bumpTrackAutomationRedrawVersion,
refreshProjectState,
} = useProjectStore();
// State to store regions
@@ -82,15 +88,54 @@ const MainContent: React.FC<MainContentProps> = ({
setActiveRegionId
});
const deleteSelectedTrackAutomationPoints = useCallback((): boolean => {
if (!activeTrackAutomationTrackId || !activeTrackAutomationType || selectedTrackAutomationPointIds.length === 0) {
return false;
}
const track = tracks.find(candidate => candidate.getId().toString() === activeTrackAutomationTrackId);
if (!track) {
return false;
}
try {
KGCore.instance().executeCommand(new DeleteTrackAutomationPointsCommand(
track.getId(),
activeTrackAutomationType,
selectedTrackAutomationPointIds
));
bumpTrackAutomationRedrawVersion();
updateTrack(track);
refreshProjectState();
return true;
} catch (error) {
console.error('Error deleting track automation points:', error);
return false;
}
}, [
activeTrackAutomationTrackId,
activeTrackAutomationType,
selectedTrackAutomationPointIds,
tracks,
updateTrack,
bumpTrackAutomationRedrawVersion,
refreshProjectState,
]);
// Register the delete function with the global manager
useEffect(() => {
regionDeleteManager.registerDeleteCallback(deleteSelectedRegions);
regionDeleteManager.registerDeleteCallback(() => {
if (deleteSelectedTrackAutomationPoints()) {
return true;
}
return deleteSelectedRegions();
});
// Cleanup on unmount
return () => {
regionDeleteManager.unregisterDeleteCallback();
};
}, [deleteSelectedRegions]);
}, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints]);
// Refs to track pending updates for verification
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
@@ -726,7 +771,7 @@ const MainContent: React.FC<MainContentProps> = ({
const isPianoRollOpen = showPianoRoll;
if (!isInPianoRoll && !isPianoRollOpen) {
const deleted = deleteSelectedRegions();
const deleted = deleteSelectedTrackAutomationPoints() || deleteSelectedRegions();
if (deleted) {
// Prevent default behavior only if regions were actually deleted
event.preventDefault();
@@ -742,7 +787,7 @@ const MainContent: React.FC<MainContentProps> = ({
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [deleteSelectedRegions, showPianoRoll]); // Dependencies for the effect
}, [deleteSelectedRegions, deleteSelectedTrackAutomationPoints, showPianoRoll]); // Dependencies for the effect
// Utility function to calculate playhead position from mouse coordinates (bar-level snapping)
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
+63 -1
View File
@@ -237,4 +237,66 @@
background: #d32f2f;
/* red */
color: #ffffff;
}
}
.pan-controls button.automation.active {
background: #d7eef9;
color: #101010;
}
.track-grid.automation-active {
position: relative;
}
.track-automation-lane {
position: absolute;
inset: 0;
z-index: 30;
cursor: default;
}
.track-automation-lane.pencil-cursor {
cursor: crosshair;
}
.track-automation-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
}
.track-automation-svg {
position: absolute;
inset: 0;
}
.track-automation-line {
pointer-events: none;
}
.track-automation-point {
cursor: pointer;
}
.track-automation-point.selected {
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.35));
}
.track-automation-value {
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
pointer-events: none;
user-select: none;
paint-order: stroke;
stroke: rgba(0, 0, 0, 0.7);
stroke-width: 2px;
stroke-linejoin: round;
}
.track-automation-selection-box {
position: absolute;
border: 1px solid rgba(135, 206, 250, 0.85);
background: rgba(135, 206, 250, 0.15);
pointer-events: none;
}
@@ -0,0 +1,555 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { KGCore } from '../../core/KGCore';
import {
CreateTrackAutomationPointsCommand,
UpdateTrackAutomationPointsCommand,
} from '../../core/commands';
import { KGTrack } from '../../core/track/KGTrack';
import { KGTrackAutomationPoint, type TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
import { useProjectStore } from '../../stores/projectStore';
import { PIANO_ROLL_CONSTANTS } from '../../constants';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { isModifierKeyPressed } from '../../util/osUtil';
import { KGMainContentState } from '../../core/state/KGMainContentState';
interface TrackAutomationLaneProps {
track: KGTrack;
automationType: TrackAutomationType;
maxBars: number;
timeSignature: { numerator: number; denominator: number };
redrawVersion?: number;
}
interface SelectionBoxState {
startX: number;
startY: number;
endX: number;
endY: number;
}
interface PreviewPoint {
absoluteBeat: number;
value: number;
}
const LANE_PADDING_Y = 12;
const POINT_RADIUS = 5;
const SELECTED_POINT_COLOR = '#FFFFFF';
const TRACK_AUTOMATION_COLORS: Record<TrackAutomationType, string> = {
volume: '#87CEFA',
pan: '#90EE90',
};
function formatAutomationValue(automationType: TrackAutomationType, value: number): string {
if (automationType === 'volume') {
return `${value >= 0 ? '+' : ''}${value.toFixed(1)}`;
}
const magnitude = Math.round(Math.abs(value) * 100);
if (magnitude === 0) {
return 'C';
}
return `${value < 0 ? 'L' : 'R'}${magnitude}`;
}
const TrackAutomationLane: React.FC<TrackAutomationLaneProps> = ({
track,
automationType,
maxBars,
timeSignature,
redrawVersion = 0,
}) => {
const laneRef = useRef<HTMLDivElement | null>(null);
const isLassoSelectingRef = useRef(false);
const lassoShiftKeyRef = useRef(false);
const selectionBoxRef = useRef<SelectionBoxState>({ startX: 0, startY: 0, endX: 0, endY: 0 });
const [selectionBoxRenderTick, setSelectionBoxRenderTick] = useState(0);
const [isModifierPressed, setIsModifierPressed] = useState(false);
const preventBackgroundClearRef = useRef(false);
const [previewPoints, setPreviewPoints] = useState<Record<string, PreviewPoint>>({});
const previewPointsRef = useRef<Record<string, PreviewPoint>>({});
const dragStateRef = useRef<{
originAbsoluteBeat: number;
originValue: number;
originClientX: number;
originClientY: number;
selectedPoints: KGTrackAutomationPoint[];
minDeltaValue: number;
maxDeltaValue: number;
hasMoved: boolean;
} | null>(null);
const {
selectedTrackAutomationPointIds,
updateTrack,
refreshProjectState,
bumpTrackAutomationRedrawVersion,
} = useProjectStore();
useEffect(() => {
const isTypingTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.hasAttribute('data-chatbox-input') ||
target.closest('.chatbox-input') !== null
);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!isTypingTarget(event.target) && isModifierKeyPressed(event)) {
setIsModifierPressed(true);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
if (!isTypingTarget(event.target) && !isModifierKeyPressed(event)) {
setIsModifierPressed(false);
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
const points = useMemo(() => track.getAutomationPoints(automationType), [track, automationType, redrawVersion]);
const selectedPointIdSet = new Set(selectedTrackAutomationPointIds.filter(id => points.some(point => point.getId() === id)));
const pointMap = new Map(points.map(point => [point.getId(), point]));
const totalBeats = maxBars * timeSignature.numerator;
const barWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')) || 40;
const beatWidth = barWidth / timeSignature.numerator;
const minValue = automationType === 'volume' ? AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB : -1;
const maxValue = automationType === 'volume' ? AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB : 1;
const toY = (value: number): number => {
const laneHeight = laneRef.current?.clientHeight ?? 120;
const usableHeight = laneHeight - LANE_PADDING_Y * 2;
const normalized = (value - minValue) / (maxValue - minValue);
return laneHeight - LANE_PADDING_Y - normalized * usableHeight;
};
const toValue = (y: number): number => {
const laneHeight = laneRef.current?.clientHeight ?? 120;
const usableHeight = laneHeight - LANE_PADDING_Y * 2;
const clampedY = Math.min(laneHeight - LANE_PADDING_Y, Math.max(LANE_PADDING_Y, y));
const normalized = (laneHeight - LANE_PADDING_Y - clampedY) / usableHeight;
const rawValue = minValue + normalized * (maxValue - minValue);
return automationType === 'volume'
? Math.max(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB, Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, rawValue))
: Math.max(-1, Math.min(1, rawValue));
};
const renderedPoints = points.map(point => {
const preview = previewPoints[point.getId()];
const absoluteBeat = preview?.absoluteBeat ?? point.getBeat();
const value = preview?.value ?? point.getValue();
return {
id: point.getId(),
absoluteBeat,
value,
x: absoluteBeat * beatWidth,
y: toY(value),
isSelected: selectedPointIdSet.has(point.getId()),
};
}).sort((left, right) => left.absoluteBeat - right.absoluteBeat || left.id.localeCompare(right.id));
const polylinePoints = renderedPoints.length > 0
? renderedPoints
.concat([{ ...renderedPoints[renderedPoints.length - 1], id: `${renderedPoints[renderedPoints.length - 1].id}-tail`, x: beatWidth * totalBeats }])
.map(point => `${point.x},${point.y}`)
.join(' ')
: '';
const commitSelection = async (nextSelectedIds: Set<string>) => {
points.forEach(point => {
if (nextSelectedIds.has(point.getId())) {
point.select();
} else {
point.deselect();
}
});
const core = KGCore.instance();
core.clearSelectedItems();
const selectedPoints = points.filter(point => nextSelectedIds.has(point.getId()));
if (selectedPoints.length > 0) {
core.addSelectedItems(selectedPoints);
}
await updateTrack(track);
};
const getLaneCoordinates = (clientX: number, clientY: number) => {
if (!laneRef.current) {
return null;
}
const rect = laneRef.current.getBoundingClientRect();
return {
x: clientX - rect.left,
y: clientY - rect.top,
};
};
const buildPreviewFromDrag = (clientX: number, clientY: number): Record<string, PreviewPoint> => {
const dragState = dragStateRef.current;
const coordinates = getLaneCoordinates(clientX, clientY);
if (!dragState || !coordinates) {
return {};
}
const rawAbsoluteBeat = coordinates.x / beatWidth;
const beatDelta = rawAbsoluteBeat - dragState.originAbsoluteBeat;
const rawDeltaValue = toValue(coordinates.y) - dragState.originValue;
const valueDelta = Math.min(dragState.maxDeltaValue, Math.max(dragState.minDeltaValue, rawDeltaValue));
const nextPreview: Record<string, PreviewPoint> = {};
dragState.selectedPoints.forEach(point => {
nextPreview[point.getId()] = {
absoluteBeat: Math.max(0, point.getBeat() + beatDelta),
value: point.getValue() + valueDelta,
};
});
return nextPreview;
};
const applyPreviewPoints = (nextPreview: Record<string, PreviewPoint>) => {
previewPointsRef.current = nextPreview;
setPreviewPoints(nextPreview);
};
const handleDragMove = (event: MouseEvent) => {
if (!dragStateRef.current) {
return;
}
const dragState = dragStateRef.current;
const movedX = Math.abs(event.clientX - dragState.originClientX);
const movedY = Math.abs(event.clientY - dragState.originClientY);
if (movedX >= PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD || movedY >= PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD) {
dragState.hasMoved = true;
}
applyPreviewPoints(buildPreviewFromDrag(event.clientX, event.clientY));
};
const cleanupDragListeners = () => {
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
};
const handleDragEnd = async () => {
const dragState = dragStateRef.current;
cleanupDragListeners();
if (!dragState) {
applyPreviewPoints({});
return;
}
const pendingPreview = Object.keys(previewPointsRef.current).length > 0
? previewPointsRef.current
: buildPreviewFromDrag(dragState.originClientX, dragState.originClientY);
if (dragState.hasMoved && Object.keys(pendingPreview).length > 0) {
KGCore.instance().executeCommand(new UpdateTrackAutomationPointsCommand(
track.getId(),
automationType,
dragState.selectedPoints.map(point => ({
pointId: point.getId(),
beat: point.getBeat(),
value: point.getValue(),
})),
dragState.selectedPoints.map(point => {
const preview = pendingPreview[point.getId()];
return {
pointId: point.getId(),
beat: preview.absoluteBeat,
value: preview.value,
};
})
));
bumpTrackAutomationRedrawVersion();
await updateTrack(track);
refreshProjectState();
preventBackgroundClearRef.current = true;
}
dragStateRef.current = null;
applyPreviewPoints({});
};
const handlePointMouseDown = async (pointId: string, event: React.MouseEvent<SVGCircleElement>) => {
event.preventDefault();
event.stopPropagation();
const point = pointMap.get(pointId);
if (!point) {
return;
}
let nextSelection = new Set(selectedPointIdSet);
if (event.shiftKey) {
if (nextSelection.has(pointId)) {
nextSelection.delete(pointId);
await commitSelection(nextSelection);
preventBackgroundClearRef.current = true;
return;
}
nextSelection.add(pointId);
} else if (!nextSelection.has(pointId)) {
nextSelection = new Set([pointId]);
}
await commitSelection(nextSelection);
const dragSelectedPoints = points.filter(candidate => nextSelection.has(candidate.getId()));
const minDeltaValue = dragSelectedPoints.reduce((currentMin, candidate) => (
Math.max(currentMin, minValue - candidate.getValue())
), Number.NEGATIVE_INFINITY);
const maxDeltaValue = dragSelectedPoints.reduce((currentMax, candidate) => (
Math.min(currentMax, maxValue - candidate.getValue())
), Number.POSITIVE_INFINITY);
dragStateRef.current = {
originAbsoluteBeat: point.getBeat(),
originValue: point.getValue(),
originClientX: event.clientX,
originClientY: event.clientY,
selectedPoints: dragSelectedPoints,
minDeltaValue,
maxDeltaValue,
hasMoved: false,
};
applyPreviewPoints({});
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
preventBackgroundClearRef.current = true;
};
const handleLassoMouseMove = (event: MouseEvent) => {
if (!isLassoSelectingRef.current || !laneRef.current) {
return;
}
const rect = laneRef.current.getBoundingClientRect();
selectionBoxRef.current = {
...selectionBoxRef.current,
endX: event.clientX - rect.left,
endY: event.clientY - rect.top,
};
setSelectionBoxRenderTick(tick => tick + 1);
};
const cleanupLassoListeners = () => {
document.removeEventListener('mousemove', handleLassoMouseMove);
document.removeEventListener('mouseup', handleLassoMouseUp);
};
const handleLassoMouseUp = async () => {
cleanupLassoListeners();
if (!isLassoSelectingRef.current) {
return;
}
const { startX, startY, endX, endY } = selectionBoxRef.current;
const left = Math.min(startX, endX);
const right = Math.max(startX, endX);
const top = Math.min(startY, endY);
const bottom = Math.max(startY, endY);
const isClick = (right - left < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD)
&& (bottom - top < PIANO_ROLL_CONSTANTS.DRAG_THRESHOLD);
isLassoSelectingRef.current = false;
setSelectionBoxRenderTick(tick => tick + 1);
if (isClick) {
return;
}
let nextSelection = lassoShiftKeyRef.current ? new Set(selectedPointIdSet) : new Set<string>();
renderedPoints.forEach(point => {
const isIntersecting = (
point.x + POINT_RADIUS >= left &&
point.x - POINT_RADIUS <= right &&
point.y + POINT_RADIUS >= top &&
point.y - POINT_RADIUS <= bottom
);
if (!isIntersecting) {
return;
}
if (lassoShiftKeyRef.current && nextSelection.has(point.id)) {
nextSelection.delete(point.id);
} else {
nextSelection.add(point.id);
}
});
await commitSelection(nextSelection);
preventBackgroundClearRef.current = true;
};
const handleBackgroundMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (isPointTarget(event.target) || dragStateRef.current) {
return;
}
if (KGMainContentState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(event) || event.button !== 0) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
selectionBoxRef.current = {
startX: event.clientX - rect.left,
startY: event.clientY - rect.top,
endX: event.clientX - rect.left,
endY: event.clientY - rect.top,
};
lassoShiftKeyRef.current = event.shiftKey;
isLassoSelectingRef.current = true;
setSelectionBoxRenderTick(tick => tick + 1);
document.addEventListener('mousemove', handleLassoMouseMove);
document.addEventListener('mouseup', handleLassoMouseUp);
};
const handleCreatePoint = async (clientX: number, clientY: number) => {
const coordinates = getLaneCoordinates(clientX, clientY);
if (!coordinates) {
return;
}
KGCore.instance().executeCommand(new CreateTrackAutomationPointsCommand(track.getId(), automationType, [{
beat: Math.max(0, coordinates.x / beatWidth),
value: toValue(coordinates.y),
}]));
bumpTrackAutomationRedrawVersion();
await updateTrack(track);
refreshProjectState();
preventBackgroundClearRef.current = true;
};
const handleBackgroundClick = async (event: React.MouseEvent<HTMLDivElement>) => {
if (isPointTarget(event.target)) {
return;
}
if (preventBackgroundClearRef.current) {
preventBackgroundClearRef.current = false;
return;
}
if (KGMainContentState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(event)) {
if (event.detail > 1) {
return;
}
await handleCreatePoint(event.clientX, event.clientY);
return;
}
if (isLassoSelectingRef.current) {
return;
}
await commitSelection(new Set());
};
const handleBackgroundDoubleClick = async (event: React.MouseEvent<HTMLDivElement>) => {
if (!isPointTarget(event.target)) {
await handleCreatePoint(event.clientX, event.clientY);
}
};
useEffect(() => {
return () => {
cleanupDragListeners();
cleanupLassoListeners();
};
}, []);
const selectionBoxStyle = {
left: `${Math.min(selectionBoxRef.current.startX, selectionBoxRef.current.endX)}px`,
top: `${Math.min(selectionBoxRef.current.startY, selectionBoxRef.current.endY)}px`,
width: `${Math.abs(selectionBoxRef.current.endX - selectionBoxRef.current.startX)}px`,
height: `${Math.abs(selectionBoxRef.current.endY - selectionBoxRef.current.startY)}px`,
};
const isPointTarget = (target: EventTarget | null): boolean => (
target instanceof Element && target.closest('.track-automation-point') !== null
);
return (
<div
ref={laneRef}
className={`track-automation-lane ${isModifierPressed ? 'pencil-cursor' : ''}`}
aria-label={`${automationType} track automation lane`}
onMouseDown={handleBackgroundMouseDown}
onClick={(event) => { void handleBackgroundClick(event); }}
onDoubleClick={(event) => { void handleBackgroundDoubleClick(event); }}
>
<div className="track-automation-overlay" />
<svg
className="track-automation-svg"
width="100%"
height="100%"
viewBox={`0 0 ${beatWidth * totalBeats} ${laneRef.current?.clientHeight ?? 120}`}
preserveAspectRatio="none"
>
{renderedPoints.length > 0 && (
<polyline
className="track-automation-line"
fill="none"
stroke={TRACK_AUTOMATION_COLORS[automationType]}
strokeWidth="2"
points={polylinePoints}
/>
)}
{renderedPoints.map(point => (
<g key={point.id}>
<circle
className={`track-automation-point${point.isSelected ? ' selected' : ''}`}
cx={point.x}
cy={point.y}
r={POINT_RADIUS}
fill={point.isSelected ? SELECTED_POINT_COLOR : TRACK_AUTOMATION_COLORS[automationType]}
stroke={point.isSelected ? SELECTED_POINT_COLOR : '#1d2428'}
strokeWidth={point.isSelected ? 3 : 2}
onMouseDown={(event) => { void handlePointMouseDown(point.id, event); }}
/>
<text
className="track-automation-value"
x={point.x + 8}
y={Math.max(14, point.y - 8)}
fill={point.isSelected ? SELECTED_POINT_COLOR : TRACK_AUTOMATION_COLORS[automationType]}
>
{formatAutomationValue(automationType, point.value)}
</text>
</g>
))}
</svg>
{isLassoSelectingRef.current && (
<div
key={selectionBoxRenderTick}
className="track-automation-selection-box"
style={selectionBoxStyle}
/>
)}
</div>
);
};
export default TrackAutomationLane;
+25 -3
View File
@@ -4,6 +4,7 @@ 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, RegionUI, ResizeAction } from '../interfaces';
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
import { KGMainContentState } from '../../core/state/KGMainContentState';
@@ -62,6 +63,9 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
onKGOneClipDrop,
}) => {
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 [containerWidth, setContainerWidth] = useState(0);
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
@@ -538,13 +542,22 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
// Filter regions for this track
const trackRegions = regions.filter(region => region.trackIndex === index);
const isAutomationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
return (
<div
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''}`}
className={`track-grid ${isDragOver ? 'drag-over' : ''} ${isDragging ? 'dragging' : ''} ${isModifierPressed ? 'pencil-cursor' : ''} ${isAutomationActive ? 'automation-active' : ''}`}
data-test-id={`track-grid-${track.getId()}`}
onDoubleClick={(e) => onDoubleClick(e, index)}
onClick={(e) => onClick && onClick(e, index)}
onDoubleClick={(e) => {
if (!isAutomationActive) {
onDoubleClick(e, index);
}
}}
onClick={(e) => {
if (!isAutomationActive) {
onClick && onClick(e, index);
}
}}
ref={trackElementRef}
onDragOver={(e) => {
if (Array.from(e.dataTransfer.types).includes('application/kgone-clip')) {
@@ -613,6 +626,15 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
/>
);
})}
{isAutomationActive && activeTrackAutomationType && (
<TrackAutomationLane
track={track}
automationType={activeTrackAutomationType}
maxBars={maxBars}
timeSignature={useProjectStore.getState().timeSignature}
redrawVersion={trackAutomationRedrawVersion}
/>
)}
</div>
);
};
+59 -2
View File
@@ -13,6 +13,7 @@ import { DEBUG_MODE } from '../../constants/uiConstants';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
const UNITY_POS = 750;
const SLIDER_MAX = 1000;
@@ -68,6 +69,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
onDragEnd
}) => {
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, importAudioToTrack, tracks: allTracks } = useProjectStore();
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
const setTrackAutomationView = useProjectStore(state => state.setTrackAutomationView);
const isSelected = selectedTrackId === track.getId().toString();
// Inline instrument dropdown removed; use InstrumentSelection panel instead
@@ -82,7 +86,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument());
const [showSettingsDropdown, setShowSettingsDropdown] = useState(false);
const [showAudioImportModal, setShowAudioImportModal] = useState(false);
const [showAutomationDropdown, setShowAutomationDropdown] = useState(false);
const settingsDropdownRef = useRef<HTMLDivElement>(null);
const automationDropdownRef = useRef<HTMLDivElement>(null);
const suppressDragRef = useRef(false);
const [volume, setVolume] = useState(track.getVolume());
const [isEditingVolume, setIsEditingVolume] = useState(false);
@@ -103,13 +109,20 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
) {
setShowSettingsDropdown(false);
}
if (
showAutomationDropdown &&
automationDropdownRef.current &&
!automationDropdownRef.current.contains(event.target as Node)
) {
setShowAutomationDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showSettingsDropdown]);
}, [showSettingsDropdown, showAutomationDropdown]);
// Sync currentInstrument state with actual track instrument value
const instrumentFromTrack = track instanceof KGMidiTrack ? track.getInstrument() : 'acoustic_grand_piano';
@@ -299,6 +312,25 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
setShowSettingsDropdown(!showSettingsDropdown);
};
const handleAutomationButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setSelectedTrack(track.getId().toString());
if (automationActive) {
setTrackAutomationView(track.getId().toString(), null);
setShowAutomationDropdown(false);
return;
}
setShowAutomationDropdown(open => !open);
};
const handleAutomationTypeSelect = (value: string) => {
setSelectedTrack(track.getId().toString());
setTrackAutomationView(track.getId().toString(), value as TrackAutomationType);
setShowAutomationDropdown(false);
};
const automationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
// Handle settings action
const handleSettingsAction = async (action: string) => {
if (action === 'Delete Track') {
@@ -415,6 +447,31 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
<div className="pan-controls">
<button className={`solo${solo ? ' active' : ''}`} onClick={handleToggleSolo}>S</button>
<button className={`mute${muted ? ' active' : ''}`} onClick={handleToggleMute}>M</button>
<div style={{ position: 'relative' }} ref={automationDropdownRef}>
<button
className={`automation${automationActive ? ' active' : ''}`}
onClick={handleAutomationButtonClick}
title="Track automation"
aria-label="Track automation"
>
A
</button>
<div style={{ position: 'absolute', top: 0, left: 'calc(100% + 6px)', zIndex: 10000 }}>
<KGDropdown
options={[
{ label: 'Volume', value: 'volume' },
{ label: 'Pan', value: 'pan' },
]}
value={activeTrackAutomationType ?? ''}
onChange={handleAutomationTypeSelect}
label="Automation"
hideButton={true}
isOpen={showAutomationDropdown}
onToggle={setShowAutomationDropdown}
className="automation-dropdown"
/>
</div>
</div>
<div>
{isAudioTrack ? (
<button className="instrument" onClick={handleAudioImportClick} title="Import Audio">
@@ -459,4 +516,4 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
);
};
export default TrackInfoItem;
export default TrackInfoItem;