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, PIANO_ROLL_AUTOMATION_OPTIONS, type PianoRollAutomationType, } from './pianoRollAutomation'; interface AutomationPoint { id: string; kind: 'pitch-bend' | 'controller'; controller: number | null; relativeBeat: number; absoluteBeat: number; value: number; label: string; } interface PianoRollAutomationLaneProps { activeRegion: KGMidiRegion | null; automationType: PianoRollAutomationType; maxBars: number; timeSignature: { numerator: number; denominator: number }; bpm?: number; redrawVersion?: number; horizontalScrollLeft?: number; 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 = ({ activeRegion, automationType, maxBars, timeSignature, redrawVersion = 0, horizontalScrollLeft = 0, onHorizontalWheel, }) => { const laneRef = useRef(null); const isLassoSelectingRef = useRef(false); const lassoShiftKeyRef = useRef(false); const [isModifierPressed, setIsModifierPressed] = useState(false); const selectionBoxRef = useRef({ 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>({}); const previewPointsRef = useRef>({}); 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) { return; } const updateHeight = () => { setLaneHeight(Math.max(Math.round(element.clientHeight), MIN_LANE_HEIGHT)); }; updateHeight(); const observer = new ResizeObserver(() => { updateHeight(); }); observer.observe(element); return () => { observer.disconnect(); }; }, []); useEffect(() => { const element = laneRef.current; if (!element) { return; } const handleWheel = (event: WheelEvent) => { const horizontalDelta = Math.abs(event.deltaX) > 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0); if (horizontalDelta === 0) { return; } event.preventDefault(); onHorizontalWheel?.(horizontalDelta); }; element.addEventListener('wheel', handleWheel, { passive: false }); return () => { element.removeEventListener('wheel', handleWheel); }; }, [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(() => { if (!activeRegion) { return []; } const regionStartBeat = activeRegion.getStartFromBeat(); 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())}`, })); } const controller = getControllerNumberForAutomationType(automationType); if (controller === null) { return []; } 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 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 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, 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' || renderedPointsSorted.length === 0) { return ''; } const linePoints = [...renderedPointsSorted]; const lastPoint = linePoints[linePoints.length - 1]; linePoints.push({ ...lastPoint, id: `${lastPoint.id}-tail`, x: beatWidth * totalBeats + keyWidth, }); return linePoints.map(point => `${point.x},${point.y}`).join(' '); })(); const stepSegments = (() => { if (interpolationMode !== 'step' || renderedPointsSorted.length === 0) { return []; } return renderedPointsSorted.map((point, index) => ({ id: `${point.id}-step`, x1: point.x, y1: point.y, x2: index < renderedPointsSorted.length - 1 ? renderedPointsSorted[index + 1].x : beatWidth * totalBeats + keyWidth, })); })(); const commitSelection = async (nextSelectedIds: Set) => { 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 = []; 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 => { 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 = {}; dragState.selectedPoints.forEach(point => { nextPreview[point.id] = { absoluteBeat: point.absoluteBeat + beatDelta, value: point.value + 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 || !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) => { 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(); 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) => { 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) => { 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) => { 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 (
{laneLabel}
{ void handleBackgroundClick(event); }} onDoubleClick={(event) => { void handleBackgroundDoubleClick(event); }} >
{interpolationMode === 'linear' && renderedPoints.length > 0 && ( )} {interpolationMode === 'step' && stepSegments.map(segment => ( ))} {renderedPoints.map(point => { const labelY = Math.max(14, Math.min(laneHeight - 6, point.y - 10)); return ( { void handlePointMouseDown(point.id, event); }} /> {point.label} ); })} {isLassoSelectingRef.current && (
)}
{renderedPointsSorted.length === 0 && (
No {laneLabel} events in this region
)}
); }; export default PianoRollAutomationLane;