feat: implemented track level automation
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user