feat: added edit MIDI notes' attribute feature

This commit is contained in:
Xiaohan-Tian
2026-05-02 18:49:23 -07:00
parent 90aee14265
commit 7b74687daf
7 changed files with 502 additions and 8 deletions
@@ -0,0 +1,308 @@
import React, { useState, useRef, useEffect } from 'react';
import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGCore } from '../../core/KGCore';
import { UpdateNotePropertiesCommand } from '../../core/commands/note/UpdateNotePropertiesCommand';
import { useProjectStore } from '../../stores/projectStore';
import { showAlert } from '../../util/dialogUtil';
interface NoteAttributeBarProps {
selectedNotes: KGMidiNote[];
isSpectrogram: boolean;
activeRegion: KGMidiRegion | null;
}
type TextField = 'pitch' | 'length';
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
function parsePitchInput(raw: string, notes: KGMidiNote[]): number[] | null {
const trimmed = raw.trim();
if (trimmed.startsWith('+') || trimmed.startsWith('-')) {
const delta = parseInt(trimmed, 10);
if (isNaN(delta)) return null;
return notes.map(n => clamp(n.getPitch() + delta, 0, 127));
}
const abs = parseInt(trimmed, 10);
if (isNaN(abs)) return null;
return notes.map(() => clamp(abs, 0, 127));
}
function parseLengthInput(raw: string): number | null {
const v = parseFloat(raw.trim());
if (isNaN(v) || v <= 0) return null;
return v;
}
interface VelocitySession {
originalVelocities: number[];
notes: KGMidiNote[];
}
const NoteAttributeBar: React.FC<NoteAttributeBarProps> = ({ selectedNotes, isSpectrogram, activeRegion }) => {
const { tracks, updateTrack } = useProjectStore();
// Text field popup state (pitch / length)
const [openTextField, setOpenTextField] = useState<TextField | null>(null);
const [textInputValue, setTextInputValue] = useState('');
// Velocity slider popup state
const [velocityOpen, setVelocityOpen] = useState(false);
const [sliderValue, setSliderValue] = useState(64);
const velocitySessionRef = useRef<VelocitySession | null>(null);
const barRef = useRef<HTMLDivElement>(null);
const commitVelocityRef = useRef<(() => void) | null>(null);
// ── Text field helpers ──────────────────────────────────────────────────────
const commitTextField = () => {
if (!openTextField || !activeRegion) { setOpenTextField(null); return; }
const snapshots = selectedNotes.map(n => ({
noteId: n.getId(),
pitch: n.getPitch(),
velocity: n.getVelocity(),
startBeat: n.getStartBeat(),
endBeat: n.getEndBeat(),
}));
const updates: { noteId: string; pitch?: number; endBeat?: number }[] = [];
if (openTextField === 'pitch') {
const newPitches = parsePitchInput(textInputValue, selectedNotes);
if (!newPitches) { setOpenTextField(null); return; }
selectedNotes.forEach((note, i) => updates.push({ noteId: note.getId(), pitch: newPitches[i] }));
} else {
const newLength = parseLengthInput(textInputValue);
if (!newLength) { setOpenTextField(null); return; }
selectedNotes.forEach(note => updates.push({ noteId: note.getId(), endBeat: note.getStartBeat() + newLength }));
}
if (updates.length > 0) {
const command = new UpdateNotePropertiesCommand(activeRegion.getId(), snapshots, updates);
KGCore.instance().executeCommand(command);
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) updateTrack(track);
}
setOpenTextField(null);
};
const handleTextKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') commitTextField();
if (e.key === 'Escape') setOpenTextField(null);
};
// ── Velocity slider helpers ─────────────────────────────────────────────────
const commitVelocity = () => {
const session = velocitySessionRef.current;
if (!session || !activeRegion) { setVelocityOpen(false); return; }
// Build snapshots from original velocities captured at open time
const snapshots = session.notes.map((n, i) => ({
noteId: n.getId(),
pitch: n.getPitch(),
velocity: session.originalVelocities[i], // original, before live edits
startBeat: n.getStartBeat(),
endBeat: n.getEndBeat(),
}));
const updates = session.notes.map(n => ({ noteId: n.getId(), velocity: sliderValue }));
// Restore originals so the command's execute() applies cleanly
session.notes.forEach((n, i) => n.setVelocity(session.originalVelocities[i]));
const command = new UpdateNotePropertiesCommand(activeRegion.getId(), snapshots, updates);
KGCore.instance().executeCommand(command);
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) updateTrack(track);
velocitySessionRef.current = null;
setVelocityOpen(false);
};
const cancelVelocity = () => {
const session = velocitySessionRef.current;
if (session) {
session.notes.forEach((n, i) => n.setVelocity(session.originalVelocities[i]));
const track = tracks.find(t => t.getId().toString() === activeRegion?.getTrackId());
if (track) updateTrack(track);
}
velocitySessionRef.current = null;
setVelocityOpen(false);
};
// Keep ref current so the outside-click handler always calls the latest commit
commitVelocityRef.current = commitVelocity;
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value, 10);
setSliderValue(val);
// Live-apply to notes so colors update immediately
velocitySessionRef.current?.notes.forEach(n => n.setVelocity(val));
const track = tracks.find(t => t.getId().toString() === activeRegion?.getTrackId());
if (track) updateTrack(track);
};
const handleSliderKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') cancelVelocity();
};
// ── Outside-click handler ───────────────────────────────────────────────────
useEffect(() => {
const isAnyOpen = openTextField !== null || velocityOpen;
if (!isAnyOpen) return;
const handleMouseDown = (e: MouseEvent) => {
if (barRef.current && !barRef.current.contains(e.target as Node)) {
if (velocityOpen) {
commitVelocityRef.current?.();
} else {
setOpenTextField(null);
}
}
};
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [openTextField, velocityOpen]);
if (isSpectrogram || selectedNotes.length === 0) {
return <div className="note-attribute-bar" ref={barRef} />;
}
// ── Compute display values ─────────────────────────────────────────────────
let pitch: string;
let length: string;
let velocity: string;
let pitchDefault: string;
let lengthDefault: string;
let velocityDefault: number;
if (selectedNotes.length === 0) {
pitch = 'N/A'; length = 'N/A'; velocity = 'N/A';
pitchDefault = ''; lengthDefault = ''; velocityDefault = 64;
} else if (selectedNotes.length === 1) {
const note = selectedNotes[0];
pitch = String(note.getPitch());
length = (note.getEndBeat() - note.getStartBeat()).toFixed(2);
velocity = String(note.getVelocity());
pitchDefault = pitch; lengthDefault = length; velocityDefault = note.getVelocity();
} else {
const pitches = selectedNotes.map(n => n.getPitch());
const lengths = selectedNotes.map(n => (n.getEndBeat() - n.getStartBeat()).toFixed(2));
const velocities = selectedNotes.map(n => n.getVelocity());
pitch = pitches.every(p => p === pitches[0]) ? String(pitches[0]) : '--';
length = lengths.every(l => l === lengths[0]) ? lengths[0] : '--';
velocity = velocities.every(v => v === velocities[0]) ? String(velocities[0]) : '--';
pitchDefault = pitch === '--' ? '' : pitch;
lengthDefault = length === '--' ? '' : length;
velocityDefault = velocity === '--' ? 64 : velocities[0];
}
// ── Field click handlers ───────────────────────────────────────────────────
const handleTextFieldClick = (field: TextField, defaultVal: string) => {
if (selectedNotes.length === 0) {
showAlert('Please select one or more notes to edit their properties.');
return;
}
setTextInputValue(defaultVal);
setOpenTextField(field);
};
const handleVelocityClick = () => {
if (selectedNotes.length === 0) {
showAlert('Please select one or more notes to edit their properties.');
return;
}
velocitySessionRef.current = {
originalVelocities: selectedNotes.map(n => n.getVelocity()),
notes: [...selectedNotes],
};
setSliderValue(velocityDefault);
setVelocityOpen(true);
};
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="note-attribute-bar" ref={barRef}>
{/* Pitch */}
<span className="attr-item">
<span className="attr-label">Pitch:</span>
<div className="quant-dropdown-container">
<button className="quant-button" onClick={() => handleTextFieldClick('pitch', pitchDefault)}>
{pitch}
</button>
{openTextField === 'pitch' && (
<div className="note-attr-popup">
<input
type="text"
className="note-attr-input"
value={textInputValue}
onChange={e => setTextInputValue(e.target.value)}
onKeyDown={handleTextKeyDown}
placeholder="0127 or ±n"
autoFocus
/>
</div>
)}
</div>
</span>
{/* Length */}
<span className="attr-item">
<span className="attr-label">Length:</span>
<div className="quant-dropdown-container">
<button className="quant-button" onClick={() => handleTextFieldClick('length', lengthDefault)}>
{length}
</button>
{openTextField === 'length' && (
<div className="note-attr-popup">
<input
type="text"
className="note-attr-input"
value={textInputValue}
onChange={e => setTextInputValue(e.target.value)}
onKeyDown={handleTextKeyDown}
placeholder="beats e.g. 1.00"
autoFocus
/>
</div>
)}
</div>
</span>
{/* Velocity */}
<span className="attr-item">
<span className="attr-label">Velocity:</span>
<div className="quant-dropdown-container">
<button className="quant-button" onClick={handleVelocityClick}>
{velocity}
</button>
{velocityOpen && (
<div className="piano-roll-zoom-popup">
<input
type="range"
min="0"
max="127"
step="1"
value={sliderValue}
onChange={handleSliderChange}
onKeyDown={handleSliderKeyDown}
autoFocus
/>
<span className="piano-roll-zoom-value">{sliderValue}</span>
</div>
)}
</div>
</span>
</div>
);
};
export default NoteAttributeBar;
+26 -1
View File
@@ -2,6 +2,20 @@ import React, { useState, useRef, useEffect } from 'react';
import { PIANO_ROLL_CONSTANTS, DEBUG_MODE } from '../../constants';
import { useProjectStore } from '../../stores/projectStore';
// Purple (vel=0) → green (vel=64) → red (vel=127), matching Logic Pro
function velocityToColor(v: number, alpha = 1): string {
const lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t);
let r: number, g: number, b: number;
if (v <= 64) {
const t = v / 64;
r = lerp(123, 90, t); g = lerp(95, 176, t); b = lerp(160, 106, t);
} else {
const t = (v - 64) / 63;
r = lerp(90, 255, t); g = lerp(176, 85, t); b = lerp(106, 85, t);
}
return alpha === 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
interface PianoNoteProps {
id: string;
index: number;
@@ -9,6 +23,7 @@ interface PianoNoteProps {
top: number;
width: number;
height: number;
velocity: number;
onResizeStart?: (noteId: string, resizeEdge: 'start' | 'end', initialX: number) => void;
onResize?: (noteId: string, resizeEdge: 'start' | 'end', deltaX: number) => void;
onResizeEnd?: (noteId: string, resizeEdge: 'start' | 'end') => void;
@@ -25,6 +40,7 @@ const PianoNote: React.FC<PianoNoteProps> = ({
top,
width,
height,
velocity,
onResizeStart,
onResize,
onResizeEnd,
@@ -41,6 +57,7 @@ const PianoNote: React.FC<PianoNoteProps> = ({
const [resizeEdge, setResizeEdge] = useState<'none' | 'start' | 'end'>('none');
const [isResizing, setIsResizing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// Use refs to track states for immediate access
const isResizingRef = useRef<boolean>(false);
@@ -80,6 +97,7 @@ const PianoNote: React.FC<PianoNoteProps> = ({
// Reset cursor when mouse leaves
const handleMouseLeave = () => {
setIsHovered(false);
if (!isResizingRef.current && !isDraggingRef.current) {
setCursor('default');
setResizeEdge('none');
@@ -250,9 +268,16 @@ const PianoNote: React.FC<PianoNoteProps> = ({
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
cursor: cursor
cursor: cursor,
backgroundColor: velocityToColor(velocity),
boxShadow: isResizing
? `0 0 10px ${velocityToColor(velocity, 0.7)}`
: isHovered
? `0 0 5px ${velocityToColor(velocity, 0.5)}`
: undefined,
}}
onMouseMove={handleMouseMove}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
id={id}
+64 -4
View File
@@ -18,8 +18,6 @@
/* Piano notes */
.piano-note {
position: absolute;
background-color: #ff5555;
/* Red color */
border: 1px solid #999;
border-radius: 2px;
z-index: 10;
@@ -30,7 +28,6 @@
.piano-note:hover {
filter: brightness(1.1);
box-shadow: 0 0 5px rgba(255, 85, 85, 0.5);
}
.piano-note.dragging {
@@ -42,7 +39,6 @@
.piano-note.resizing {
opacity: 0.8;
box-shadow: 0 0 10px rgba(255, 85, 85, 0.7);
z-index: 100;
}
@@ -77,6 +73,70 @@
/* Ensure toolbar and its dropdowns appear above piano roll content */
}
/* Note attribute bar */
.note-attribute-bar {
height: 30px;
background-color: #252525;
border-bottom: 1px solid #3a3a3a;
display: flex;
align-items: center;
padding: 0 10px;
user-select: none;
gap: 24px;
font-size: 11px;
color: #aaa;
}
.note-attribute-bar .attr-item {
display: flex;
align-items: center;
}
.note-attribute-bar .attr-label {
color: #777;
margin-right: 4px;
}
.note-attribute-bar .attr-value {
color: #ddd;
}
.note-attribute-bar .quant-button {
font-size: 11px;
margin-left: 0;
padding: 2px 6px;
}
.note-attr-popup {
position: absolute;
top: 100%;
left: 0;
background-color: #2d2d2d;
border: 1px solid #444;
border-radius: 3px;
padding: 6px 8px;
z-index: 1500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
margin-top: 2px;
min-width: 120px;
}
.note-attr-input {
background-color: #1e1e1e;
border: 1px solid #555;
border-radius: 2px;
color: #e0e0e0;
font-size: 11px;
padding: 3px 6px;
width: 100%;
outline: none;
box-sizing: border-box;
}
.note-attr-input:focus {
border-color: #4a6b8a;
}
.piano-roll-toolbar .tool-button {
width: 20px;
height: 20px;
+11 -2
View File
@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState, useCallback, useLayoutEffect } from 'react';
import React, { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from 'react';
import './PianoRoll.css';
import type { MouseEvent } from 'react';
import { useProjectStore } from '../../stores/projectStore';
@@ -8,6 +8,7 @@ import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, TOOLBAR_CONSTANTS } from '../../constants';
import PianoRollHeader from './PianoRollHeader';
import PianoRollToolbar from './PianoRollToolbar';
import NoteAttributeBar from './NoteAttributeBar';
import PianoRollContent from './PianoRollContent';
import { KGCore } from '../../core/KGCore';
import { KGMidiNote } from '../../core/midi/KGMidiNote';
@@ -41,7 +42,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 } = useProjectStore();
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds } = useProjectStore();
// Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
@@ -73,6 +74,12 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [activeRegion, setActiveRegion] = useState<KGMidiRegion | null>(null);
const selectedNotes = useMemo(
() => activeRegion?.getNotes().filter(n => selectedNoteIds.includes(n.getId())) ?? [],
[activeRegion, selectedNoteIds]
);
const pianoRollRef = useRef<HTMLDivElement>(null);
const pianoRollContentRef = useRef<HTMLDivElement>(null);
const pianoGridRef = useRef<HTMLDivElement>(null);
@@ -1019,6 +1026,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
onZoomChange={handleZoomChange}
/>
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isSpectrogram} activeRegion={activeRegion} />
<PianoRollContent
contentRef={pianoRollContentRef}
pianoGridRef={pianoGridRef}
@@ -211,6 +211,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
top={parseFloat(tempStyle.top as string)}
width={parseFloat(tempStyle.width as string)}
height={noteHeight}
velocity={note.getVelocity()}
onResizeStart={handleNoteResizeStart}
onResize={handleNoteResize}
onResizeEnd={handleNoteResizeEnd}
@@ -221,7 +222,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
/>
);
}
return (
<PianoNote
key={`note-${noteId}`}
@@ -231,6 +232,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
top={top}
width={width}
height={noteHeight}
velocity={note.getVelocity()}
onResizeStart={handleNoteResizeStart}
onResize={handleNoteResize}
onResizeEnd={handleNoteResizeEnd}