feat: implemented editable automation lane in piano roll window
This commit is contained in:
@@ -1,10 +1,23 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { CreateMidiEventsCommand } from '../../core/commands/note/CreateMidiEventsCommand';
|
||||
import { UpdateControllerEventPropertiesCommand } from '../../core/commands/note/UpdateControllerEventPropertiesCommand';
|
||||
import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand';
|
||||
import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent';
|
||||
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import {
|
||||
MIDI_PITCH_BEND_MAX,
|
||||
MIDI_PITCH_BEND_MIN,
|
||||
clampMidiControllerValue,
|
||||
clampMidiPitchBendValue,
|
||||
midiPitchBendToSignedValue,
|
||||
} from '../../util/midiUtil';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { PIANO_ROLL_CONSTANTS } from '../../constants';
|
||||
import { getSnappedBeatPosition } from './pianoRollSnap';
|
||||
import {
|
||||
getAutomationInterpolationMode,
|
||||
getControllerNumberForAutomationType,
|
||||
@@ -14,6 +27,9 @@ import {
|
||||
|
||||
interface AutomationPoint {
|
||||
id: string;
|
||||
kind: 'pitch-bend' | 'controller';
|
||||
controller: number | null;
|
||||
relativeBeat: number;
|
||||
absoluteBeat: number;
|
||||
value: number;
|
||||
label: string;
|
||||
@@ -30,9 +46,23 @@ interface PianoRollAutomationLaneProps {
|
||||
onHorizontalWheel?: (delta: number) => void;
|
||||
}
|
||||
|
||||
interface SelectionBoxState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
}
|
||||
|
||||
interface PreviewPoint {
|
||||
absoluteBeat: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const AUTOMATION_COLOR = '#87CEFA';
|
||||
const SELECTED_POINT_COLOR = '#FFFFFF';
|
||||
const LANE_PADDING_Y = 16;
|
||||
const MIN_LANE_HEIGHT = 160;
|
||||
const POINT_RADIUS = 5;
|
||||
|
||||
const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
activeRegion,
|
||||
@@ -44,10 +74,38 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
onHorizontalWheel,
|
||||
}) => {
|
||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||
const isLassoSelectingRef = useRef(false);
|
||||
const lassoShiftKeyRef = useRef(false);
|
||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||
const selectionBoxRef = useRef<SelectionBoxState>({ startX: 0, startY: 0, endX: 0, endY: 0 });
|
||||
const [selectionBoxRenderTick, setSelectionBoxRenderTick] = useState(0);
|
||||
const preventBackgroundClearRef = useRef(false);
|
||||
const dragStateRef = useRef<{
|
||||
primaryPointId: string;
|
||||
originAbsoluteBeat: number;
|
||||
originValue: number;
|
||||
originClientX: number;
|
||||
originClientY: number;
|
||||
selectedPoints: AutomationPoint[];
|
||||
minDeltaValue: number;
|
||||
maxDeltaValue: number;
|
||||
hasMoved: boolean;
|
||||
} | null>(null);
|
||||
const [previewPoints, setPreviewPoints] = useState<Record<string, PreviewPoint>>({});
|
||||
const previewPointsRef = useRef<Record<string, PreviewPoint>>({});
|
||||
const [laneHeight, setLaneHeight] = useState(MIN_LANE_HEIGHT);
|
||||
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
|
||||
const keyWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-width')) || 60;
|
||||
|
||||
const {
|
||||
tracks,
|
||||
updateTrack,
|
||||
refreshProjectState,
|
||||
bumpAutomationRedrawVersion,
|
||||
selectedPitchBendIds,
|
||||
selectedControllerEventIds,
|
||||
} = useProjectStore();
|
||||
|
||||
useEffect(() => {
|
||||
const element = laneRef.current;
|
||||
if (!element) {
|
||||
@@ -92,6 +150,50 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
};
|
||||
}, [onHorizontalWheel]);
|
||||
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (isTypingTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const points = useMemo<AutomationPoint[]>(() => {
|
||||
if (!activeRegion) {
|
||||
return [];
|
||||
@@ -101,6 +203,9 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
if (automationType === 'pitch-bend') {
|
||||
return activeRegion.getPitchBends().map((pitchBend) => ({
|
||||
id: pitchBend.getId(),
|
||||
kind: 'pitch-bend' as const,
|
||||
controller: null,
|
||||
relativeBeat: pitchBend.getBeat(),
|
||||
absoluteBeat: regionStartBeat + pitchBend.getBeat(),
|
||||
value: pitchBend.getValue(),
|
||||
label: `${midiPitchBendToSignedValue(pitchBend.getValue())}`,
|
||||
@@ -114,69 +219,553 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
|
||||
return activeRegion.getControllerEvents(controller).map((event) => ({
|
||||
id: event.getId(),
|
||||
kind: 'controller' as const,
|
||||
controller,
|
||||
relativeBeat: event.getBeat(),
|
||||
absoluteBeat: regionStartBeat + event.getBeat(),
|
||||
value: event.getValue(),
|
||||
label: `${event.getValue()}`,
|
||||
}));
|
||||
}, [activeRegion, automationType, redrawVersion]);
|
||||
|
||||
const selectedPointIds = automationType === 'pitch-bend'
|
||||
? selectedPitchBendIds
|
||||
: selectedControllerEventIds.filter(id => points.some(point => point.id === id));
|
||||
const selectedPointIdSet = new Set(selectedPointIds);
|
||||
const pointMap = new Map(points.map(point => [point.id, point]));
|
||||
const parentTrack = activeRegion
|
||||
? tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null
|
||||
: null;
|
||||
|
||||
const selectedOption = PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === automationType);
|
||||
const laneLabel = selectedOption?.label ?? automationType;
|
||||
const interpolationMode = getAutomationInterpolationMode(automationType);
|
||||
const totalBeats = maxBars * timeSignature.numerator;
|
||||
const totalWidth = 'calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width))';
|
||||
const minValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MIN : 0;
|
||||
const maxValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MAX : 127;
|
||||
|
||||
const toY = (value: number): number => {
|
||||
const minValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MIN : 0;
|
||||
const maxValue = automationType === 'pitch-bend' ? MIDI_PITCH_BEND_MAX : 127;
|
||||
const usableHeight = laneHeight - LANE_PADDING_Y * 2;
|
||||
const normalized = (value - minValue) / (maxValue - minValue);
|
||||
return laneHeight - LANE_PADDING_Y - normalized * usableHeight;
|
||||
};
|
||||
|
||||
const svgPoints = points.map(point => {
|
||||
const toValue = (y: number): number => {
|
||||
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 === 'pitch-bend'
|
||||
? clampMidiPitchBendValue(Math.round(rawValue))
|
||||
: clampMidiControllerValue(Math.round(rawValue));
|
||||
};
|
||||
|
||||
const getPointLabel = (value: number): string => (
|
||||
automationType === 'pitch-bend' ? `${midiPitchBendToSignedValue(value)}` : `${value}`
|
||||
);
|
||||
|
||||
const renderedPoints = points.map(point => {
|
||||
const preview = previewPoints[point.id];
|
||||
const absoluteBeat = preview?.absoluteBeat ?? point.absoluteBeat;
|
||||
const value = preview?.value ?? point.value;
|
||||
|
||||
return {
|
||||
...point,
|
||||
x: point.absoluteBeat * beatWidth + keyWidth,
|
||||
y: toY(point.value),
|
||||
absoluteBeat,
|
||||
value,
|
||||
label: getPointLabel(value),
|
||||
x: absoluteBeat * beatWidth + keyWidth,
|
||||
y: toY(value),
|
||||
isSelected: selectedPointIdSet.has(point.id),
|
||||
};
|
||||
});
|
||||
const renderedPointsSorted = [...renderedPoints].sort((leftPoint, rightPoint) => {
|
||||
if (leftPoint.absoluteBeat !== rightPoint.absoluteBeat) {
|
||||
return leftPoint.absoluteBeat - rightPoint.absoluteBeat;
|
||||
}
|
||||
|
||||
return leftPoint.id.localeCompare(rightPoint.id);
|
||||
});
|
||||
|
||||
const polylinePoints = (() => {
|
||||
if (interpolationMode === 'step') {
|
||||
if (interpolationMode === 'step' || renderedPointsSorted.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (svgPoints.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const renderedPoints = [...svgPoints];
|
||||
const lastPoint = renderedPoints[renderedPoints.length - 1];
|
||||
renderedPoints.push({
|
||||
const linePoints = [...renderedPointsSorted];
|
||||
const lastPoint = linePoints[linePoints.length - 1];
|
||||
linePoints.push({
|
||||
...lastPoint,
|
||||
id: `${lastPoint.id}-tail`,
|
||||
x: beatWidth * totalBeats + keyWidth,
|
||||
});
|
||||
|
||||
return renderedPoints
|
||||
.map(point => `${point.x},${point.y}`)
|
||||
.join(' ');
|
||||
return linePoints.map(point => `${point.x},${point.y}`).join(' ');
|
||||
})();
|
||||
|
||||
const stepSegments = (() => {
|
||||
if (interpolationMode !== 'step' || svgPoints.length === 0) {
|
||||
if (interpolationMode !== 'step' || renderedPointsSorted.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return svgPoints.map((point, index) => ({
|
||||
return renderedPointsSorted.map((point, index) => ({
|
||||
id: `${point.id}-step`,
|
||||
x1: point.x,
|
||||
y1: point.y,
|
||||
x2: index < svgPoints.length - 1 ? svgPoints[index + 1].x : beatWidth * totalBeats + keyWidth,
|
||||
x2: index < renderedPointsSorted.length - 1 ? renderedPointsSorted[index + 1].x : beatWidth * totalBeats + keyWidth,
|
||||
}));
|
||||
})();
|
||||
|
||||
const commitSelection = async (nextSelectedIds: Set<string>) => {
|
||||
if (!activeRegion || !parentTrack) return;
|
||||
|
||||
activeRegion.getNotes().forEach(note => note.deselect());
|
||||
activeRegion.getPitchBends().forEach(pitchBend => {
|
||||
if (nextSelectedIds.has(pitchBend.getId())) pitchBend.select();
|
||||
else pitchBend.deselect();
|
||||
});
|
||||
activeRegion.getControllerEventsByType().forEach(events => {
|
||||
events.forEach(controllerEvent => {
|
||||
if (nextSelectedIds.has(controllerEvent.getId())) controllerEvent.select();
|
||||
else controllerEvent.deselect();
|
||||
});
|
||||
});
|
||||
|
||||
const selectedEvents: Array<KGMidiPitchBend | KGMidiControllerEvent> = [];
|
||||
activeRegion.getPitchBends().forEach(pitchBend => {
|
||||
if (nextSelectedIds.has(pitchBend.getId())) {
|
||||
selectedEvents.push(pitchBend);
|
||||
}
|
||||
});
|
||||
activeRegion.getControllerEventsByType().forEach(events => {
|
||||
events.forEach(controllerEvent => {
|
||||
if (nextSelectedIds.has(controllerEvent.getId())) {
|
||||
selectedEvents.push(controllerEvent);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const core = KGCore.instance();
|
||||
core.clearSelectedItems();
|
||||
if (selectedEvents.length > 0) {
|
||||
core.addSelectedItems(selectedEvents);
|
||||
}
|
||||
|
||||
await updateTrack(parentTrack);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRegion || !parentTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visiblePointIds = new Set(points.map(point => point.id));
|
||||
const hiddenSelection = automationType === 'pitch-bend'
|
||||
? selectedControllerEventIds.length > 0
|
||||
: selectedPitchBendIds.length > 0 || selectedControllerEventIds.some(id => !visiblePointIds.has(id));
|
||||
|
||||
if (!hiddenSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeRegion.getPitchBends().forEach(pitchBend => {
|
||||
if (!visiblePointIds.has(pitchBend.getId())) {
|
||||
pitchBend.deselect();
|
||||
}
|
||||
});
|
||||
activeRegion.getControllerEventsByType().forEach(events => {
|
||||
events.forEach(controllerEvent => {
|
||||
if (!visiblePointIds.has(controllerEvent.getId())) {
|
||||
controllerEvent.deselect();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const core = KGCore.instance();
|
||||
const visibleSelectedItems = core.getSelectedItems().filter(item => visiblePointIds.has(item.getId()));
|
||||
core.clearSelectedItems();
|
||||
if (visibleSelectedItems.length > 0) {
|
||||
core.addSelectedItems(visibleSelectedItems);
|
||||
}
|
||||
|
||||
void updateTrack(parentTrack);
|
||||
}, [activeRegion, automationType, parentTrack, points, selectedControllerEventIds, selectedPitchBendIds, updateTrack]);
|
||||
|
||||
const getLaneCoordinates = (clientX: number, clientY: number) => {
|
||||
if (!laneRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = laneRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: clientX - rect.left + horizontalScrollLeft,
|
||||
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 - keyWidth) / beatWidth;
|
||||
const snappedAbsoluteBeat = KGPianoRollState.instance().getCurrentSnap() === 'NO SNAP'
|
||||
? rawAbsoluteBeat
|
||||
: getSnappedBeatPosition(rawAbsoluteBeat, KGPianoRollState.instance().getCurrentSnap());
|
||||
const beatDelta = snappedAbsoluteBeat - 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.id] = {
|
||||
absoluteBeat: point.absoluteBeat + beatDelta,
|
||||
value: point.value + 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 || !activeRegion || !parentTrack) {
|
||||
dragStateRef.current = null;
|
||||
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) {
|
||||
if (automationType === 'pitch-bend') {
|
||||
const snapshots = dragState.selectedPoints.map(point => ({
|
||||
pitchBendId: point.id,
|
||||
beat: point.relativeBeat,
|
||||
value: point.value,
|
||||
}));
|
||||
const updates = dragState.selectedPoints.map(point => {
|
||||
const preview = pendingPreview[point.id];
|
||||
return {
|
||||
pitchBendId: point.id,
|
||||
beat: preview.absoluteBeat - activeRegion.getStartFromBeat(),
|
||||
value: preview.value,
|
||||
};
|
||||
});
|
||||
KGCore.instance().executeCommand(new UpdatePitchBendPropertiesCommand(activeRegion.getId(), snapshots, updates));
|
||||
} else {
|
||||
const controller = getControllerNumberForAutomationType(automationType);
|
||||
if (controller !== null) {
|
||||
const snapshots = dragState.selectedPoints.map(point => ({
|
||||
controllerEventId: point.id,
|
||||
controller,
|
||||
beat: point.relativeBeat,
|
||||
value: point.value,
|
||||
}));
|
||||
const updates = dragState.selectedPoints.map(point => {
|
||||
const preview = pendingPreview[point.id];
|
||||
return {
|
||||
controllerEventId: point.id,
|
||||
controller,
|
||||
beat: preview.absoluteBeat - activeRegion.getStartFromBeat(),
|
||||
value: preview.value,
|
||||
};
|
||||
});
|
||||
KGCore.instance().executeCommand(new UpdateControllerEventPropertiesCommand(activeRegion.getId(), snapshots, updates));
|
||||
}
|
||||
}
|
||||
|
||||
bumpAutomationRedrawVersion();
|
||||
await updateTrack(parentTrack);
|
||||
refreshProjectState();
|
||||
preventBackgroundClearRef.current = true;
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
applyPreviewPoints({});
|
||||
};
|
||||
|
||||
const handlePointMouseDown = async (pointId: string, event: React.MouseEvent<SVGCircleElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!activeRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
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.id));
|
||||
const minDeltaValue = dragSelectedPoints.reduce((currentMin, candidate) => (
|
||||
Math.max(currentMin, minValue - candidate.value)
|
||||
), Number.NEGATIVE_INFINITY);
|
||||
const maxDeltaValue = dragSelectedPoints.reduce((currentMax, candidate) => (
|
||||
Math.min(currentMax, maxValue - candidate.value)
|
||||
), Number.POSITIVE_INFINITY);
|
||||
|
||||
dragStateRef.current = {
|
||||
primaryPointId: pointId,
|
||||
originAbsoluteBeat: point.absoluteBeat,
|
||||
originValue: point.value,
|
||||
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>();
|
||||
|
||||
renderedPointsSorted.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 (KGPianoRollState.instance().getActiveTool() === 'pencil' || isModifierKeyPressed(event)) {
|
||||
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) => {
|
||||
if (!activeRegion || !parentTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
const coordinates = getLaneCoordinates(clientX, clientY);
|
||||
if (!coordinates) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth;
|
||||
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
|
||||
const absoluteBeat = currentSnap === 'NO SNAP'
|
||||
? rawAbsoluteBeat
|
||||
: getSnappedBeatPosition(rawAbsoluteBeat, currentSnap);
|
||||
const relativeBeat = absoluteBeat - activeRegion.getStartFromBeat();
|
||||
const value = toValue(coordinates.y);
|
||||
|
||||
if (automationType === 'pitch-bend') {
|
||||
const command = new CreateMidiEventsCommand([], [{
|
||||
regionId: activeRegion.getId(),
|
||||
beat: relativeBeat,
|
||||
value,
|
||||
}]);
|
||||
KGCore.instance().executeCommand(command);
|
||||
const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend;
|
||||
if (createdPitchBend) {
|
||||
await commitSelection(new Set([createdPitchBend.getId()]));
|
||||
}
|
||||
} else {
|
||||
const controller = getControllerNumberForAutomationType(automationType);
|
||||
if (controller === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const command = new CreateMidiEventsCommand([], [], [{
|
||||
regionId: activeRegion.getId(),
|
||||
controller,
|
||||
beat: relativeBeat,
|
||||
value,
|
||||
}]);
|
||||
KGCore.instance().executeCommand(command);
|
||||
const createdControllerEvent = command.getCreatedControllerEvents()[0]?.controllerEvent;
|
||||
if (createdControllerEvent) {
|
||||
await commitSelection(new Set([createdControllerEvent.getId()]));
|
||||
}
|
||||
}
|
||||
|
||||
bumpAutomationRedrawVersion();
|
||||
await updateTrack(parentTrack);
|
||||
refreshProjectState();
|
||||
preventBackgroundClearRef.current = true;
|
||||
};
|
||||
|
||||
const handleBackgroundClick = async (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isPointTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preventBackgroundClearRef.current) {
|
||||
preventBackgroundClearRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (KGPianoRollState.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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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('.piano-roll-automation-point') !== null
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="piano-roll-automation-lane"
|
||||
@@ -188,8 +777,11 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
<div className="piano-roll-automation-gutter" />
|
||||
<div className="piano-roll-automation-lane-label">{laneLabel}</div>
|
||||
<div
|
||||
className="piano-roll-automation-scroll-layer"
|
||||
className={`piano-roll-automation-scroll-layer ${isModifierPressed ? 'pencil-cursor' : ''}`}
|
||||
style={{ transform: `translateX(-${horizontalScrollLeft}px)` }}
|
||||
onMouseDown={handleBackgroundMouseDown}
|
||||
onClick={(event) => { void handleBackgroundClick(event); }}
|
||||
onDoubleClick={(event) => { void handleBackgroundDoubleClick(event); }}
|
||||
>
|
||||
<div className="piano-roll-automation-grid" />
|
||||
<svg
|
||||
@@ -199,7 +791,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
viewBox={`0 0 ${beatWidth * totalBeats + keyWidth} ${laneHeight}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{interpolationMode === 'linear' && points.length > 0 && (
|
||||
{interpolationMode === 'linear' && renderedPoints.length > 0 && (
|
||||
<polyline
|
||||
className="piano-roll-automation-line"
|
||||
fill="none"
|
||||
@@ -220,25 +812,26 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
{svgPoints.map(point => {
|
||||
{renderedPoints.map(point => {
|
||||
const labelY = Math.max(14, Math.min(laneHeight - 6, point.y - 10));
|
||||
|
||||
return (
|
||||
<g key={point.id}>
|
||||
<circle
|
||||
className="piano-roll-automation-point"
|
||||
className={`piano-roll-automation-point${point.isSelected ? ' selected' : ''}`}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="5"
|
||||
fill={AUTOMATION_COLOR}
|
||||
stroke="#1d2428"
|
||||
strokeWidth="2"
|
||||
r={POINT_RADIUS}
|
||||
fill={point.isSelected ? SELECTED_POINT_COLOR : AUTOMATION_COLOR}
|
||||
stroke={point.isSelected ? SELECTED_POINT_COLOR : '#1d2428'}
|
||||
strokeWidth={point.isSelected ? 3 : 2}
|
||||
onMouseDown={(event) => { void handlePointMouseDown(point.id, event); }}
|
||||
/>
|
||||
<text
|
||||
className="piano-roll-automation-value"
|
||||
x={point.x + 6}
|
||||
y={labelY}
|
||||
fill={AUTOMATION_COLOR}
|
||||
fill={point.isSelected ? SELECTED_POINT_COLOR : AUTOMATION_COLOR}
|
||||
>
|
||||
{point.label}
|
||||
</text>
|
||||
@@ -246,8 +839,15 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{isLassoSelectingRef.current && (
|
||||
<div
|
||||
key={selectionBoxRenderTick}
|
||||
className="piano-roll-automation-selection-box"
|
||||
style={selectionBoxStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{points.length === 0 && (
|
||||
{renderedPointsSorted.length === 0 && (
|
||||
<div className="piano-roll-automation-empty-state">
|
||||
No {laneLabel} events in this region
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user