feat: added v1 read-only version list event panel

This commit is contained in:
Xiaohan-Tian
2026-05-05 17:17:08 -07:00
parent c2e68e388c
commit 3743f9cab2
6 changed files with 584 additions and 11 deletions
+189
View File
@@ -0,0 +1,189 @@
.list-event-panel {
display: flex;
flex-direction: column;
width: var(--chat-box-width);
background-color: #2d2d2d;
border-left: 1px solid #3a3a3a;
flex-shrink: 0;
overflow: hidden;
}
.list-event-panel.is-hidden {
display: none;
}
.list-event-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #3a3a3a;
height: 40px;
border-bottom: 1px solid #4a4a4a;
padding: 0 15px;
flex-shrink: 0;
}
.list-event-panel-header h3 {
color: #e0e0e0;
font-size: 12px;
font-weight: bold;
margin: 0;
}
.list-event-panel-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 12px;
gap: 12px;
}
.list-event-tabs {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.list-event-tab {
flex: 1;
background-color: #1e1e1e;
color: #999;
border: none;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
min-height: 20px;
padding: 5px 6px;
cursor: default;
transition: all 0.2s ease;
}
.list-event-tab:hover {
color: #e0e0e0;
}
.list-event-tab.active {
background-color: #5a9fd4;
color: #fff;
}
.list-event-empty-state {
color: #888;
font-size: 11px;
line-height: 1.5;
text-align: center;
padding: 20px 8px;
background-color: #252525;
border-radius: 6px;
}
.list-event-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.list-event-toolbar-group {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.list-event-toolbar-group-right {
margin-left: auto;
}
.list-event-add-button {
width: 22px;
height: 20px;
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;
padding: 0;
font-size: 11px;
}
.list-event-add-button:hover {
background-color: #464646;
border-color: #5a5a5a;
}
.list-event-dropdown-button,
.list-event-quant-button {
min-width: 92px;
font-size: 11px;
}
.list-event-dropdown-button {
margin-left: 0;
}
.list-event-table-shell {
flex: 1;
min-height: 0;
overflow: auto;
background-color: #232323;
border: 1px solid #373737;
border-radius: 6px;
}
.list-event-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.list-event-table thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: #343434;
color: #bfc6d2;
font-size: 11px;
font-weight: 600;
text-align: left;
height: 20px;
padding: 2px 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list-event-table tbody tr {
color: #e0e0e0;
cursor: default;
}
.list-event-table tbody tr:nth-child(odd) {
background-color: #282828;
}
.list-event-table tbody tr:nth-child(even) {
background-color: #303030;
}
.list-event-table tbody tr.selected {
background-color: #5a9fd4;
color: #fff;
}
.list-event-table td {
height: 20px;
padding: 2px 12px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 0;
}
+345
View File
@@ -0,0 +1,345 @@
import React, { useRef, useState } from 'react';
import './ListEventPanel.css';
import { FaPlus } from 'react-icons/fa';
import KGDropdown from './common/KGDropdown';
import { useProjectStore } from '../stores/projectStore';
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 { isModifierKeyPressed } from '../util/osUtil';
import { PIANO_ROLL_CONSTANTS } from '../constants';
interface ListEventPanelProps {
isVisible: boolean;
}
interface NoteRowData {
id: string;
note: KGMidiNote;
absoluteStartBeat: number;
durationBeats: number;
}
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;
}
if (normalizedBeat >= timeSignature.numerator) {
normalizedBeat = 0;
normalizedBar += 1;
}
return `${normalizedBar + 1} ${normalizedBeat + 1} ${tick}`;
};
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;
}
return `${normalizedBeats} ${tick}`;
};
const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
const {
tracks,
activeRegionId,
selectedRegionIds,
timeSignature,
selectedNoteIds,
updateTrack,
refreshProjectState
} = useProjectStore();
const [eventType, setEventType] = useState('notes');
const [quantPosition, setQuantPosition] = useState<string>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8');
const rangeAnchorNoteIdRef = useRef<string | null>(null);
const resolvedRegionId = selectedRegionIds.length > 1
? activeRegionId
: selectedRegionIds.length === 1
? selectedRegionIds[0]
: activeRegionId;
let activeMidiRegion: KGMidiRegion | null = null;
let parentTrack = null as typeof tracks[number] | null;
if (resolvedRegionId) {
for (const track of tracks) {
const region = track.getRegions().find(candidate => candidate.getId() === resolvedRegionId);
if (region instanceof KGMidiRegion) {
activeMidiRegion = region;
parentTrack = track;
break;
}
}
}
const noteRows: NoteRowData[] = activeMidiRegion
? [...activeMidiRegion.getNotes()]
.sort((a, b) => {
if (a.getStartBeat() !== b.getStartBeat()) return a.getStartBeat() - b.getStartBeat();
if (a.getPitch() !== b.getPitch()) return a.getPitch() - b.getPitch();
return a.getId().localeCompare(b.getId());
})
.map(note => ({
id: note.getId(),
note,
absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(),
durationBeats: note.getEndBeat() - note.getStartBeat()
}))
: [];
const selectedNoteIdSet = new Set(selectedNoteIds);
const commitSelection = (nextSelectedIds: Set<string>) => {
if (!activeMidiRegion || !parentTrack) return;
const selectedNotes = activeMidiRegion.getNotes().filter(note => nextSelectedIds.has(note.getId()));
const core = KGCore.instance();
activeMidiRegion.getNotes().forEach(note => {
if (nextSelectedIds.has(note.getId())) {
note.select();
} else {
note.deselect();
}
});
core.clearSelectedItems();
if (selectedNotes.length > 0) {
core.addSelectedItems(selectedNotes);
}
void updateTrack(parentTrack);
};
const handleRowClick = (noteId: string, rowIndex: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (!activeMidiRegion) return;
const isModifierPressed = isModifierKeyPressed(event);
const nextSelectedIds = new Set(selectedNoteIdSet);
if (event.shiftKey) {
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;
if (!isModifierPressed) {
nextSelectedIds.clear();
}
for (let index = rangeStartIndex; index <= rangeEndIndex; index += 1) {
nextSelectedIds.add(noteRows[index].id);
}
} else if (isModifierPressed) {
if (nextSelectedIds.has(noteId)) {
nextSelectedIds.delete(noteId);
} else {
nextSelectedIds.add(noteId);
}
rangeAnchorNoteIdRef.current = noteId;
} else {
nextSelectedIds.clear();
nextSelectedIds.add(noteId);
rangeAnchorNoteIdRef.current = noteId;
}
if (event.shiftKey && rangeAnchorNoteIdRef.current === null) {
rangeAnchorNoteIdRef.current = noteId;
}
commitSelection(nextSelectedIds);
};
const handleTableBackgroundMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return;
rangeAnchorNoteIdRef.current = null;
commitSelection(new Set());
};
const quantizeSelectedNotes = (quantValue: string) => {
if (!activeMidiRegion || !parentTrack) return;
const denominator = parseInt(quantValue.split('/')[1], 10);
if (Number.isNaN(denominator)) return;
const selectedNotes = activeMidiRegion
.getNotes()
.filter(note => selectedNoteIdSet.has(note.getId()));
if (selectedNotes.length === 0) return;
const quantizationStep = 4 / denominator;
selectedNotes.forEach(note => {
const currentStartBeat = note.getStartBeat();
const duration = note.getEndBeat() - currentStartBeat;
const quantizedStartBeat = Math.round(currentStartBeat / quantizationStep) * quantizationStep;
note.setStartBeat(quantizedStartBeat);
note.setEndBeat(quantizedStartBeat + duration);
});
void updateTrack(parentTrack);
refreshProjectState();
};
const quantizeSelectedNoteLengths = (quantValue: string) => {
if (!activeMidiRegion || !parentTrack) return;
const denominator = parseInt(quantValue.split('/')[1], 10);
if (Number.isNaN(denominator)) return;
const selectedNotes = activeMidiRegion
.getNotes()
.filter(note => selectedNoteIdSet.has(note.getId()));
if (selectedNotes.length === 0) return;
const quantizationStep = 4 / denominator;
selectedNotes.forEach(note => {
const startBeat = note.getStartBeat();
const currentDuration = note.getEndBeat() - startBeat;
let quantizedDuration = currentDuration < quantizationStep
? quantizationStep
: Math.round(currentDuration / quantizationStep) * quantizationStep;
quantizedDuration = Math.max(PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH, quantizedDuration);
note.setEndBeat(startBeat + quantizedDuration);
});
void updateTrack(parentTrack);
refreshProjectState();
};
return (
<div className={`list-event-panel${isVisible ? '' : ' is-hidden'}`}>
<div className="list-event-panel-header">
<h3>List Event</h3>
</div>
<div className="list-event-panel-body">
<div className="list-event-tabs" role="tablist" aria-label="Event types">
<button className="list-event-tab active" aria-pressed="true">Notes</button>
<button className="list-event-tab" aria-pressed="false">Pitch Bends</button>
<button className="list-event-tab" aria-pressed="false">Controller</button>
</div>
{!activeMidiRegion ? (
<div className="list-event-empty-state">
Please select a MIDI region, or open one in the Piano Roll, to view its event list.
</div>
) : (
<>
<div className="list-event-toolbar">
<div className="list-event-toolbar-group">
<button
className="list-event-add-button"
title="Add note UI only for now"
type="button"
>
<FaPlus />
</button>
<KGDropdown
options={EVENT_TYPE_OPTIONS}
value={eventType}
onChange={setEventType}
label="Event Type"
buttonClassName="list-event-dropdown-button"
showValueAsLabel={true}
/>
</div>
<div className="list-event-toolbar-group list-event-toolbar-group-right">
<KGDropdown
options={KGPianoRollState.QUANT_POS_OPTIONS}
value={quantPosition}
onChange={(value) => {
setQuantPosition(value);
quantizeSelectedNotes(value);
}}
label="Qua. Pos."
buttonClassName="list-event-quant-button"
/>
<KGDropdown
options={KGPianoRollState.QUANT_LEN_OPTIONS}
value={quantLength}
onChange={(value) => {
setQuantLength(value);
quantizeSelectedNoteLengths(value);
}}
label="Qua. Len."
buttonClassName="list-event-quant-button"
/>
</div>
</div>
<div className="list-event-table-shell" onMouseDown={handleTableBackgroundMouseDown}>
<table className="list-event-table">
<thead>
<tr>
<th>Position</th>
<th>Status</th>
<th>Num</th>
<th>Val</th>
<th>Length/Info</th>
</tr>
</thead>
<tbody>
{noteRows.map((row, index) => (
(() => {
const positionText = formatEventPosition(row.absoluteStartBeat, timeSignature);
const statusText = 'Note';
const noteText = pitchToNoteNameString(row.note.getPitch());
const velocityText = String(row.note.getVelocity());
const lengthText = formatEventLength(row.durationBeats);
return (
<tr
key={row.id}
className={selectedNoteIdSet.has(row.id) ? 'selected' : ''}
onClick={(event) => handleRowClick(row.id, index, event)}
>
<td title={positionText}>{positionText}</td>
<td title={statusText}>{statusText}</td>
<td title={noteText}>{noteText}</td>
<td title={velocityText}>{velocityText}</td>
<td title={lengthText}>{lengthText}</td>
</tr>
);
})()
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div>
);
};
export default ListEventPanel;
+24 -4
View File
@@ -18,7 +18,7 @@ import { KGProject, type KeySignature } from '../core/KGProject';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { plainToInstance } from 'class-transformer';
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6';
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6';
import { KGMainContentState } from '../core/state/KGMainContentState';
import { regionDeleteManager } from '../util/regionDeleteUtil';
import { SplitRegionCommand } from '../core/commands/region/SplitRegionCommand';
@@ -48,7 +48,7 @@ const Toolbar: React.FC = () => {
barWidthMultiplier, setBarWidthMultiplier,
isLooping, toggleLoop,
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
toggleChatBox, toggleSettings, toggleKGOnePanel, toggleListEventPanel, showKGOnePanel, showListEventPanel, showChatBox, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
isRecording, startRecording, stopRecording,
// Piano roll state/actions
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
@@ -939,6 +939,13 @@ const Toolbar: React.FC = () => {
toggleKGOnePanel();
};
const handleListEventClick = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log("List Event button clicked");
}
toggleListEventPanel();
};
// Handle Piano button click: open piano roll if closed, targeting active or selected region
const handlePianoButtonClick = async () => {
if (DEBUG_MODE.TOOLBAR) {
@@ -1058,6 +1065,7 @@ const Toolbar: React.FC = () => {
</div>
</div>
<button title="Import" onClick={handleImportProject}><FaUpload /></button>
<button title="Settings" onClick={handleSettingsClick}><FaCog /></button>
<div className="toolbar-separator"></div>
<button title="Undo" onClick={handleUndoClick}><FaUndo /></button>
<button title="Redo" onClick={handleRedoClick}><FaRedo /></button>
@@ -1188,7 +1196,6 @@ const Toolbar: React.FC = () => {
</div>
</div>
</div>
<button title="Settings" onClick={handleSettingsClick}><FaCog /></button>
<button
title={isKGOneEnabled ? 'K.G.One Music Generator' : 'K.G.One integration is disabled — enable it in Settings'}
onClick={handleKGOneClick}
@@ -1198,7 +1205,20 @@ const Toolbar: React.FC = () => {
>
<FaWandMagicSparkles />
</button>
<button title="Chat" onClick={handleChatClick}><FaComments /></button>
<button
title="Chat"
onClick={handleChatClick}
className={showChatBox ? 'active' : ''}
>
<FaComments />
</button>
<button
title="List Event Editor"
onClick={handleListEventClick}
className={showListEventPanel ? 'active' : ''}
>
<FaListUl />
</button>
</div>
</div>
+2 -2
View File
@@ -46,7 +46,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}) => {
const isSpectrogram = mode === 'spectrogram';
const isHybrid = mode === 'hybrid';
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds } = useProjectStore();
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showListEventPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds } = useProjectStore();
// Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
@@ -140,7 +140,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300;
let availableWidth = window.innerWidth;
if (showChatBox) availableWidth -= chatBoxWidth;
if (showChatBox || showKGOnePanel || showListEventPanel) availableWidth -= chatBoxWidth;
if (showInstrumentSelection) availableWidth -= instrumentPanelWidth;
// Ensure a sensible minimum starting width