feat: implemented inline edit for the list event panel

This commit is contained in:
Xiaohan-Tian
2026-05-05 17:55:06 -07:00
parent 3743f9cab2
commit 8f453f8f4c
5 changed files with 740 additions and 47 deletions
+14
View File
@@ -187,3 +187,17 @@
text-overflow: ellipsis;
max-width: 0;
}
.list-event-cell-input {
width: calc(100% + 8px);
height: 16px;
margin: 0 -4px;
padding: 0 4px;
border: 1px solid #6d94ca;
border-radius: 3px;
background-color: #1f1f1f;
color: #f3f6fb;
font-size: 11px;
line-height: 16px;
outline: none;
}
+452 -39
View File
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import './ListEventPanel.css';
import { FaPlus } from 'react-icons/fa';
import KGDropdown from './common/KGDropdown';
@@ -7,9 +7,22 @@ import { KGCore } from '../core/KGCore';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGMidiNote } from '../core/midi/KGMidiNote';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import { beatsToBar, pitchToNoteNameString } from '../util/midiUtil';
import {
formatMidiEventLength,
formatMidiEventPosition,
MIDI_EVENT_TICKS_PER_BEAT,
noteNameToPitch,
parseMidiEventLengthDelta,
parseMidiEventLength,
parseMidiEventPositionDelta,
parseMidiEventPosition,
pitchToNoteNameString
} from '../util/midiUtil';
import { isModifierKeyPressed } from '../util/osUtil';
import { PIANO_ROLL_CONSTANTS } from '../constants';
import { CreateNoteCommand } from '../core/commands';
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
import { showAlert } from '../util/dialogUtil';
interface ListEventPanelProps {
isVisible: boolean;
@@ -22,45 +35,54 @@ interface NoteRowData {
durationBeats: number;
}
type EditableColumn = 'position' | 'num' | 'val' | 'length';
interface EditingCell {
noteId: string;
column: EditableColumn;
value: string;
}
const EVENT_TYPE_OPTIONS = [{ label: 'Notes', value: 'notes' }];
const EVENT_POSITION_TICKS_PER_BEAT = 480;
const formatEventPosition = (
beats: number,
timeSignature: { numerator: number; denominator: number }
): string => {
const { bar, beatInBar } = beatsToBar(beats, timeSignature);
const beatInteger = Math.floor(beatInBar);
let tick = Math.round((beatInBar - beatInteger) * EVENT_POSITION_TICKS_PER_BEAT);
let normalizedBeat = beatInteger;
let normalizedBar = bar;
if (tick >= EVENT_POSITION_TICKS_PER_BEAT) {
tick = 0;
normalizedBeat += 1;
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
return { error: 'Velocity must be an integer between 0 and 127.' };
}
if (normalizedBeat >= timeSignature.numerator) {
normalizedBeat = 0;
normalizedBar += 1;
const velocity = parseInt(trimmed, 10);
if (velocity < 0 || velocity > 127) {
return { error: 'Velocity must be between 0 and 127.' };
}
return `${normalizedBar + 1} ${normalizedBeat + 1} ${tick}`;
return { velocity };
};
const formatEventLength = (
beats: number
): string => {
const fullBeats = Math.floor(beats);
let tick = Math.round((beats - fullBeats) * EVENT_POSITION_TICKS_PER_BEAT);
let normalizedBeats = fullBeats;
if (tick >= EVENT_POSITION_TICKS_PER_BEAT) {
tick = 0;
normalizedBeats += 1;
const parseVelocityDeltaInput = (raw: string): { delta: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^[+-]\d+$/.test(trimmed)) {
return { error: 'Use velocity delta like +10 or -5.' };
}
return `${normalizedBeats} ${tick}`;
return { delta: parseInt(trimmed, 10) };
};
const parseNoteNameInput = (raw: string): { pitch: number } | { error: string } => {
try {
return { pitch: noteNameToPitch(raw.trim()) };
} catch {
return { error: 'Use note names like C3, C#3, or Cb3.' };
}
};
const parsePitchDeltaInput = (raw: string): { delta: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^[+-]\d+$/.test(trimmed)) {
return { error: 'Use note delta like +2 or -1 when editing Num in delta mode.' };
}
return { delta: parseInt(trimmed, 10) };
};
const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
@@ -70,6 +92,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
selectedRegionIds,
timeSignature,
selectedNoteIds,
playheadPosition,
updateTrack,
refreshProjectState
} = useProjectStore();
@@ -77,7 +100,11 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const [eventType, setEventType] = useState('notes');
const [quantPosition, setQuantPosition] = useState<string>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8');
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const rangeAnchorNoteIdRef = useRef<string | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
const suppressBlurCommitRef = useRef(false);
const pendingSingleClickSelectionRef = useRef<number | null>(null);
const resolvedRegionId = selectedRegionIds.length > 1
? activeRegionId
@@ -116,6 +143,21 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const selectedNoteIdSet = new Set(selectedNoteIds);
useEffect(() => {
if (editingCell) {
editInputRef.current?.focus();
editInputRef.current?.select();
}
}, [editingCell?.noteId, editingCell?.column]);
useEffect(() => {
return () => {
if (pendingSingleClickSelectionRef.current !== null) {
window.clearTimeout(pendingSingleClickSelectionRef.current);
}
};
}, []);
const commitSelection = (nextSelectedIds: Set<string>) => {
if (!activeMidiRegion || !parentTrack) return;
@@ -138,13 +180,212 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
void updateTrack(parentTrack);
};
const clearPendingSingleClickSelection = () => {
if (pendingSingleClickSelectionRef.current !== null) {
window.clearTimeout(pendingSingleClickSelectionRef.current);
pendingSingleClickSelectionRef.current = null;
}
};
const startEditingCell = (noteId: string, column: EditableColumn, value: string) => {
clearPendingSingleClickSelection();
setEditingCell({ noteId, column, value });
};
const cancelEditingCell = () => {
setEditingCell(null);
};
const commitEditingCell = async () => {
if (!editingCell || !activeMidiRegion || !parentTrack) return;
const note = activeMidiRegion.getNotes().find(candidate => candidate.getId() === editingCell.noteId);
if (!note) {
setEditingCell(null);
return;
}
const targetNotes = selectedNoteIdSet.has(note.getId()) && selectedNoteIds.length > 1
? activeMidiRegion.getNotes().filter(candidate => selectedNoteIdSet.has(candidate.getId()))
: [note];
const snapshots = targetNotes.map(targetNote => ({
noteId: targetNote.getId(),
pitch: targetNote.getPitch(),
velocity: targetNote.getVelocity(),
startBeat: targetNote.getStartBeat(),
endBeat: targetNote.getEndBeat()
}));
const updates: Array<{ noteId: string; pitch?: number; velocity?: number; startBeat?: number; endBeat?: number }> = [];
const trimmedValue = editingCell.value.trim();
const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-');
if (editingCell.column === 'position') {
if (isDeltaEdit) {
const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
const nextStartBeat = targetNote.getStartBeat() + parsed.deltaBeats;
if (nextStartBeat < 0) {
await showAlert('Position delta would move one or more notes before the start of the current MIDI region.');
return;
}
updates.push({
noteId: targetNote.getId(),
startBeat: nextStartBeat,
endBeat: nextStartBeat + currentDuration
});
}
} else {
const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
const relativeStartBeat = parsed.absoluteBeat - activeMidiRegion.getStartFromBeat();
if (relativeStartBeat < 0) {
await showAlert('Position cannot be earlier than the start of the current MIDI region.');
return;
}
for (const targetNote of targetNotes) {
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
updates.push({
noteId: targetNote.getId(),
startBeat: relativeStartBeat,
endBeat: relativeStartBeat + currentDuration
});
}
}
}
if (editingCell.column === 'num') {
if (isDeltaEdit) {
const parsed = parsePitchDeltaInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const nextPitch = targetNote.getPitch() + parsed.delta;
if (nextPitch < 0 || nextPitch > 127) {
await showAlert('Num delta would move one or more notes outside the MIDI pitch range 0127.');
return;
}
updates.push({ noteId: targetNote.getId(), pitch: nextPitch });
}
} else {
const parsed = parseNoteNameInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
updates.push({ noteId: targetNote.getId(), pitch: parsed.pitch });
}
}
}
if (editingCell.column === 'val') {
if (isDeltaEdit) {
const parsed = parseVelocityDeltaInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const nextVelocity = targetNote.getVelocity() + parsed.delta;
if (nextVelocity < 0 || nextVelocity > 127) {
await showAlert('Velocity delta would move one or more notes outside the valid range 0127.');
return;
}
updates.push({ noteId: targetNote.getId(), velocity: nextVelocity });
}
} else {
const parsed = parseVelocityInput(trimmedValue);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
updates.push({ noteId: targetNote.getId(), velocity: parsed.velocity });
}
}
}
if (editingCell.column === 'length') {
if (isDeltaEdit) {
const parsed = parseMidiEventLengthDelta(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
const currentDuration = targetNote.getEndBeat() - targetNote.getStartBeat();
const nextDuration = currentDuration + parsed.deltaBeats;
if (nextDuration <= 0) {
await showAlert('Length delta would make one or more notes non-positive in duration.');
return;
}
updates.push({
noteId: targetNote.getId(),
endBeat: targetNote.getStartBeat() + nextDuration
});
}
} else {
const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
}
for (const targetNote of targetNotes) {
updates.push({
noteId: targetNote.getId(),
endBeat: targetNote.getStartBeat() + parsed.duration
});
}
}
}
if (updates.length === 0) {
setEditingCell(null);
return;
}
const command = new UpdateNotePropertiesCommand(activeMidiRegion.getId(), snapshots, updates);
KGCore.instance().executeCommand(command);
await updateTrack(parentTrack);
refreshProjectState();
setEditingCell(null);
};
const handleRowClick = (noteId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
event.stopPropagation();
if (editingCell) return;
if (!activeMidiRegion) return;
const isModifierPressed = isModifierKeyPressed(event);
const nextSelectedIds = new Set(selectedNoteIdSet);
const isAlreadySelected = selectedNoteIdSet.has(noteId);
const hasMultiSelection = selectedNoteIds.length > 1;
if (event.shiftKey) {
clearPendingSingleClickSelection();
const anchorIndex = noteRows.findIndex(row => row.id === rangeAnchorNoteIdRef.current);
const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex;
const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex;
@@ -157,6 +398,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
nextSelectedIds.add(noteRows[index].id);
}
} else if (isModifierPressed) {
clearPendingSingleClickSelection();
if (nextSelectedIds.has(noteId)) {
nextSelectedIds.delete(noteId);
} else {
@@ -164,6 +406,18 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
}
rangeAnchorNoteIdRef.current = noteId;
} else {
if (isAlreadySelected && hasMultiSelection) {
clearPendingSingleClickSelection();
pendingSingleClickSelectionRef.current = window.setTimeout(() => {
const delayedSelection = new Set<string>([noteId]);
rangeAnchorNoteIdRef.current = noteId;
commitSelection(delayedSelection);
pendingSingleClickSelectionRef.current = null;
}, 220);
return;
}
clearPendingSingleClickSelection();
nextSelectedIds.clear();
nextSelectedIds.add(noteId);
rangeAnchorNoteIdRef.current = noteId;
@@ -177,11 +431,41 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
};
const handleTableBackgroundMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
if (event.target !== event.currentTarget) return;
clearPendingSingleClickSelection();
rangeAnchorNoteIdRef.current = null;
commitSelection(new Set());
};
const handleTableShellClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
};
const handleEditInputKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
await commitEditingCell();
}
if (event.key === 'Escape') {
event.preventDefault();
suppressBlurCommitRef.current = true;
cancelEditingCell();
}
};
const handleEditInputBlur = () => {
if (suppressBlurCommitRef.current) {
suppressBlurCommitRef.current = false;
return;
}
void commitEditingCell();
};
const quantizeSelectedNotes = (quantValue: string) => {
if (!activeMidiRegion || !parentTrack) return;
@@ -235,6 +519,45 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
refreshProjectState();
};
const handleAddNote = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!activeMidiRegion || !parentTrack) return;
const lastSelectedNoteId = [...selectedNoteIds]
.reverse()
.find(noteId => activeMidiRegion.getNotes().some(note => note.getId() === noteId));
const lastSelectedNote = lastSelectedNoteId
? activeMidiRegion.getNotes().find(note => note.getId() === lastSelectedNoteId) ?? null
: null;
const defaultLength = lastSelectedNote
? lastSelectedNote.getEndBeat() - lastSelectedNote.getStartBeat()
: KGPianoRollState.instance().getLastEditedNoteLength();
const defaultPitch = lastSelectedNote ? lastSelectedNote.getPitch() : noteNameToPitch('C4');
const defaultVelocity = lastSelectedNote ? lastSelectedNote.getVelocity() : 127;
const regionRelativePlayhead = Math.max(0, playheadPosition - activeMidiRegion.getStartFromBeat());
const command = new CreateNoteCommand(
activeMidiRegion.getId(),
regionRelativePlayhead,
regionRelativePlayhead + defaultLength,
defaultPitch,
defaultVelocity
);
KGCore.instance().executeCommand(command);
KGPianoRollState.instance().setLastEditedNoteLength(defaultLength);
const createdNote = command.getCreatedNote();
if (createdNote) {
createdNote.select();
KGCore.instance().clearSelectedItems();
KGCore.instance().addSelectedItem(createdNote);
rangeAnchorNoteIdRef.current = createdNote.getId();
}
await updateTrack(parentTrack);
refreshProjectState();
};
return (
<div className={`list-event-panel${isVisible ? '' : ' is-hidden'}`}>
<div className="list-event-panel-header">
@@ -258,8 +581,9 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
<div className="list-event-toolbar-group">
<button
className="list-event-add-button"
title="Add note UI only for now"
title="Add note at playhead"
type="button"
onClick={handleAddNote}
>
<FaPlus />
</button>
@@ -297,7 +621,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
</div>
</div>
<div className="list-event-table-shell" onMouseDown={handleTableBackgroundMouseDown}>
<div
className="list-event-table-shell"
onMouseDown={handleTableBackgroundMouseDown}
onClick={handleTableShellClick}
onDoubleClick={handleTableShellClick}
>
<table className="list-event-table">
<thead>
<tr>
@@ -311,23 +640,107 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
<tbody>
{noteRows.map((row, index) => (
(() => {
const positionText = formatEventPosition(row.absoluteStartBeat, timeSignature);
const positionText = formatMidiEventPosition(row.absoluteStartBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
const statusText = 'Note';
const noteText = pitchToNoteNameString(row.note.getPitch());
const velocityText = String(row.note.getVelocity());
const lengthText = formatEventLength(row.durationBeats);
const lengthText = formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT);
const isEditingPosition = editingCell?.noteId === row.id && editingCell.column === 'position';
const isEditingNum = editingCell?.noteId === row.id && editingCell.column === 'num';
const isEditingVal = editingCell?.noteId === row.id && editingCell.column === 'val';
const isEditingLength = editingCell?.noteId === row.id && editingCell.column === 'length';
return (
<tr
key={row.id}
className={selectedNoteIdSet.has(row.id) ? 'selected' : ''}
onClick={(event) => handleRowClick(row.id, index, event)}
onDoubleClick={(event) => {
event.stopPropagation();
clearPendingSingleClickSelection();
}}
>
<td title={positionText}>{positionText}</td>
<td
title={positionText}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingCell(row.id, 'position', positionText);
}}
>
{isEditingPosition ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : positionText}
</td>
<td title={statusText}>{statusText}</td>
<td title={noteText}>{noteText}</td>
<td title={velocityText}>{velocityText}</td>
<td title={lengthText}>{lengthText}</td>
<td
title={noteText}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingCell(row.id, 'num', noteText);
}}
>
{isEditingNum ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : noteText}
</td>
<td
title={velocityText}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingCell(row.id, 'val', velocityText);
}}
>
{isEditingVal ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : velocityText}
</td>
<td
title={lengthText}
onDoubleClick={(event) => {
event.stopPropagation();
startEditingCell(row.id, 'length', lengthText);
}}
>
{isEditingLength ? (
<input
ref={editInputRef}
className="list-event-cell-input"
value={editingCell.value}
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
onBlur={handleEditInputBlur}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
/>
) : lengthText}
</td>
</tr>
);
})()