diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 95a11fe..977cb56 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -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 = ({ savedProjectName, requestPianoRollScroll, mainContentScrollRequest, + activeTrackAutomationTrackId, + activeTrackAutomationType, + selectedTrackAutomationPointIds, + bumpTrackAutomationRedrawVersion, + refreshProjectState, } = useProjectStore(); // State to store regions @@ -82,15 +88,54 @@ const MainContent: React.FC = ({ 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>(new Map()); @@ -726,7 +771,7 @@ const MainContent: React.FC = ({ 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 = ({ 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 => { diff --git a/src/components/track/Track.css b/src/components/track/Track.css index 073cbbd..536c2c2 100644 --- a/src/components/track/Track.css +++ b/src/components/track/Track.css @@ -237,4 +237,66 @@ background: #d32f2f; /* red */ color: #ffffff; -} \ No newline at end of file +} + +.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; +} diff --git a/src/components/track/TrackAutomationLane.tsx b/src/components/track/TrackAutomationLane.tsx new file mode 100644 index 0000000..6056624 --- /dev/null +++ b/src/components/track/TrackAutomationLane.tsx @@ -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 = { + 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 = ({ + track, + automationType, + maxBars, + timeSignature, + redrawVersion = 0, +}) => { + const laneRef = useRef(null); + const isLassoSelectingRef = useRef(false); + const lassoShiftKeyRef = useRef(false); + const selectionBoxRef = useRef({ 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>({}); + const previewPointsRef = useRef>({}); + 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) => { + 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 => { + 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 = {}; + + dragState.selectedPoints.forEach(point => { + nextPreview[point.getId()] = { + absoluteBeat: Math.max(0, point.getBeat() + beatDelta), + value: point.getValue() + valueDelta, + }; + }); + + return nextPreview; + }; + + const applyPreviewPoints = (nextPreview: Record) => { + 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) => { + 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(); + 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) => { + 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) => { + 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) => { + 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 ( +
{ void handleBackgroundClick(event); }} + onDoubleClick={(event) => { void handleBackgroundDoubleClick(event); }} + > +
+ + {renderedPoints.length > 0 && ( + + )} + {renderedPoints.map(point => ( + + { void handlePointMouseDown(point.id, event); }} + /> + + {formatAutomationValue(automationType, point.value)} + + + ))} + + {isLassoSelectingRef.current && ( +
+ )} +
+ ); +}; + +export default TrackAutomationLane; diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index 35f5351..1b3742f 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -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 = ({ 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(null); const [draggingRegion, setDraggingRegion] = useState(null); @@ -538,13 +542,22 @@ const TrackGridItem: React.FC = ({ // Filter regions for this track const trackRegions = regions.filter(region => region.trackIndex === index); + const isAutomationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null; return (
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 = ({ /> ); })} + {isAutomationActive && activeTrackAutomationType && ( + + )}
); }; diff --git a/src/components/track/TrackInfoItem.tsx b/src/components/track/TrackInfoItem.tsx index 7cf9cc3..7722b67 100644 --- a/src/components/track/TrackInfoItem.tsx +++ b/src/components/track/TrackInfoItem.tsx @@ -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 = ({ 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 = ({ const [currentInstrument, setCurrentInstrument] = useState(getTrackInstrument()); const [showSettingsDropdown, setShowSettingsDropdown] = useState(false); const [showAudioImportModal, setShowAudioImportModal] = useState(false); + const [showAutomationDropdown, setShowAutomationDropdown] = useState(false); const settingsDropdownRef = useRef(null); + const automationDropdownRef = useRef(null); const suppressDragRef = useRef(false); const [volume, setVolume] = useState(track.getVolume()); const [isEditingVolume, setIsEditingVolume] = useState(false); @@ -103,13 +109,20 @@ const TrackInfoItem: React.FC = ({ ) { 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 = ({ setShowSettingsDropdown(!showSettingsDropdown); }; + const handleAutomationButtonClick = (e: React.MouseEvent) => { + 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 = ({
+
+ +
+ +
+
{isAudioTrack ? (