feat: added adding pitch bend feature and delete selected events feature to the list event panel
This commit is contained in:
@@ -93,13 +93,18 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-event-toolbar-group:first-child {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.list-event-toolbar-group-right {
|
||||
margin-left: auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-event-add-button {
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
height: 22px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
background-color: #3a3a3a;
|
||||
@@ -120,8 +125,8 @@
|
||||
}
|
||||
|
||||
.list-event-dropdown-button,
|
||||
.list-event-quant-button {
|
||||
min-width: 92px;
|
||||
.list-event-quant-button,
|
||||
.list-event-type-button {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -129,6 +134,43 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.list-event-quant-button {
|
||||
min-width: 78px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.list-event-type-button {
|
||||
min-width: 88px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.list-event-delete-button {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
background-color: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, opacity 0.18s ease;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.list-event-delete-button:hover:not(:disabled) {
|
||||
background-color: #464646;
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
.list-event-delete-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.list-event-table-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -200,4 +242,4 @@
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -32,16 +32,18 @@ describe('ListEventPanel', () => {
|
||||
it('renders note and pitch bend rows and toggles them independently', () => {
|
||||
render(<ListEventPanel isVisible={true} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Note' })).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Delete visible selected rows')).toBeDisabled();
|
||||
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
|
||||
expect(screen.getByText('Note')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Note').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Raw 12288 | 0.500 | 1.00 st')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' }));
|
||||
expect(screen.queryByText('Pitch Bend')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Note')).toBeInTheDocument();
|
||||
expect(screen.getByText('C4')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Notes' }));
|
||||
expect(screen.queryByText('Note')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C4')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' }));
|
||||
expect(screen.getByText('Pitch Bend')).toBeInTheDocument();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import './ListEventPanel.css';
|
||||
import { FaPlus } from 'react-icons/fa';
|
||||
import { FaPlus, FaTrash } from 'react-icons/fa';
|
||||
import KGDropdown from './common/KGDropdown';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from '../util/midiUtil';
|
||||
import { isModifierKeyPressed } from '../util/osUtil';
|
||||
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
||||
import { CreateNoteCommand } from '../core/commands';
|
||||
import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../core/commands';
|
||||
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
||||
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
|
||||
import { showAlert } from '../util/dialogUtil';
|
||||
@@ -61,6 +61,13 @@ interface EditingCell {
|
||||
value: string;
|
||||
}
|
||||
|
||||
type AddEventType = 'note' | 'pitch-bend';
|
||||
|
||||
const ADD_EVENT_TYPE_OPTIONS = [
|
||||
{ label: 'Note', value: 'note' },
|
||||
{ label: 'Pitch Bend', value: 'pitch-bend' },
|
||||
] as const;
|
||||
|
||||
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
|
||||
const trimmed = raw.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
@@ -147,6 +154,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
const [showPitchBends, setShowPitchBends] = useState(true);
|
||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||
const [quantLength, setQuantLength] = useState<string>('1/8');
|
||||
const [addEventType, setAddEventType] = useState<AddEventType>('note');
|
||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||
const rangeAnchorEventIdRef = useRef<string | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -206,6 +214,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
const selectedNoteIdSet = new Set(selectedNoteIds);
|
||||
const selectedPitchBendIdSet = new Set(selectedPitchBendIds);
|
||||
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds]);
|
||||
const visibleSelectedRows = eventRows.filter(row => selectedEventIdSet.has(row.id));
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCell) {
|
||||
@@ -675,41 +684,77 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
refreshProjectState();
|
||||
};
|
||||
|
||||
const handleAddNote = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleAddEvent = 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);
|
||||
rangeAnchorEventIdRef.current = createdNote.getId();
|
||||
if (addEventType === 'note') {
|
||||
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 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);
|
||||
rangeAnchorEventIdRef.current = createdNote.getId();
|
||||
}
|
||||
} else {
|
||||
const command = new CreateMidiEventsCommand([], [{
|
||||
regionId: activeMidiRegion.getId(),
|
||||
beat: regionRelativePlayhead,
|
||||
value: MIDI_PITCH_BEND_CENTER,
|
||||
}]);
|
||||
|
||||
KGCore.instance().executeCommand(command);
|
||||
const createdPitchBend = command.getCreatedPitchBends()[0]?.pitchBend;
|
||||
if (createdPitchBend) {
|
||||
createdPitchBend.select();
|
||||
KGCore.instance().clearSelectedItems();
|
||||
KGCore.instance().addSelectedItem(createdPitchBend);
|
||||
rangeAnchorEventIdRef.current = createdPitchBend.getId();
|
||||
}
|
||||
}
|
||||
|
||||
await updateTrack(parentTrack);
|
||||
refreshProjectState();
|
||||
};
|
||||
|
||||
const handleDeleteSelectedRows = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!activeMidiRegion || !parentTrack || visibleSelectedRows.length === 0) return;
|
||||
|
||||
const noteIds = visibleSelectedRows
|
||||
.filter((row): row is NoteRowData => row.type === 'note')
|
||||
.map(row => row.note.getId());
|
||||
const pitchBendIds = visibleSelectedRows
|
||||
.filter((row): row is PitchBendRowData => row.type === 'pitch-bend')
|
||||
.map(row => row.pitchBend.getId());
|
||||
|
||||
KGCore.instance().executeCommand(new DeleteMidiEventsCommand(noteIds, pitchBendIds));
|
||||
rangeAnchorEventIdRef.current = null;
|
||||
await updateTrack(parentTrack);
|
||||
refreshProjectState();
|
||||
};
|
||||
@@ -751,12 +796,20 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
<div className="list-event-toolbar-group">
|
||||
<button
|
||||
className="list-event-add-button"
|
||||
title="Add note at playhead"
|
||||
title={addEventType === 'note' ? 'Add note at playhead' : 'Add pitch bend at playhead'}
|
||||
type="button"
|
||||
onClick={handleAddNote}
|
||||
onClick={handleAddEvent}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
<KGDropdown
|
||||
options={[...ADD_EVENT_TYPE_OPTIONS]}
|
||||
value={addEventType}
|
||||
onChange={(value) => setAddEventType(value as AddEventType)}
|
||||
label="Note"
|
||||
buttonClassName="list-event-type-button"
|
||||
showValueAsLabel
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="list-event-toolbar-group list-event-toolbar-group-right">
|
||||
@@ -780,6 +833,15 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
label="Qua. Len."
|
||||
buttonClassName="list-event-quant-button"
|
||||
/>
|
||||
<button
|
||||
className="list-event-delete-button"
|
||||
title="Delete visible selected rows"
|
||||
type="button"
|
||||
onClick={handleDeleteSelectedRows}
|
||||
disabled={visibleSelectedRows.length === 0}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user