feat: added edit MIDI notes' attribute feature
This commit is contained in:
@@ -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="0–127 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;
|
||||||
@@ -2,6 +2,20 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { PIANO_ROLL_CONSTANTS, DEBUG_MODE } from '../../constants';
|
import { PIANO_ROLL_CONSTANTS, DEBUG_MODE } from '../../constants';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
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 {
|
interface PianoNoteProps {
|
||||||
id: string;
|
id: string;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -9,6 +23,7 @@ interface PianoNoteProps {
|
|||||||
top: number;
|
top: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
velocity: number;
|
||||||
onResizeStart?: (noteId: string, resizeEdge: 'start' | 'end', initialX: number) => void;
|
onResizeStart?: (noteId: string, resizeEdge: 'start' | 'end', initialX: number) => void;
|
||||||
onResize?: (noteId: string, resizeEdge: 'start' | 'end', deltaX: number) => void;
|
onResize?: (noteId: string, resizeEdge: 'start' | 'end', deltaX: number) => void;
|
||||||
onResizeEnd?: (noteId: string, resizeEdge: 'start' | 'end') => void;
|
onResizeEnd?: (noteId: string, resizeEdge: 'start' | 'end') => void;
|
||||||
@@ -25,6 +40,7 @@ const PianoNote: React.FC<PianoNoteProps> = ({
|
|||||||
top,
|
top,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
velocity,
|
||||||
onResizeStart,
|
onResizeStart,
|
||||||
onResize,
|
onResize,
|
||||||
onResizeEnd,
|
onResizeEnd,
|
||||||
@@ -41,6 +57,7 @@ const PianoNote: React.FC<PianoNoteProps> = ({
|
|||||||
const [resizeEdge, setResizeEdge] = useState<'none' | 'start' | 'end'>('none');
|
const [resizeEdge, setResizeEdge] = useState<'none' | 'start' | 'end'>('none');
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
// Use refs to track states for immediate access
|
// Use refs to track states for immediate access
|
||||||
const isResizingRef = useRef<boolean>(false);
|
const isResizingRef = useRef<boolean>(false);
|
||||||
@@ -80,6 +97,7 @@ const PianoNote: React.FC<PianoNoteProps> = ({
|
|||||||
|
|
||||||
// Reset cursor when mouse leaves
|
// Reset cursor when mouse leaves
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
|
setIsHovered(false);
|
||||||
if (!isResizingRef.current && !isDraggingRef.current) {
|
if (!isResizingRef.current && !isDraggingRef.current) {
|
||||||
setCursor('default');
|
setCursor('default');
|
||||||
setResizeEdge('none');
|
setResizeEdge('none');
|
||||||
@@ -250,9 +268,16 @@ const PianoNote: React.FC<PianoNoteProps> = ({
|
|||||||
top: `${top}px`,
|
top: `${top}px`,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${height}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}
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
/* Piano notes */
|
/* Piano notes */
|
||||||
.piano-note {
|
.piano-note {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background-color: #ff5555;
|
|
||||||
/* Red color */
|
|
||||||
border: 1px solid #999;
|
border: 1px solid #999;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -30,7 +28,6 @@
|
|||||||
|
|
||||||
.piano-note:hover {
|
.piano-note:hover {
|
||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
box-shadow: 0 0 5px rgba(255, 85, 85, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.piano-note.dragging {
|
.piano-note.dragging {
|
||||||
@@ -42,7 +39,6 @@
|
|||||||
|
|
||||||
.piano-note.resizing {
|
.piano-note.resizing {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
box-shadow: 0 0 10px rgba(255, 85, 85, 0.7);
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +73,70 @@
|
|||||||
/* Ensure toolbar and its dropdowns appear above piano roll content */
|
/* 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 {
|
.piano-roll-toolbar .tool-button {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -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 './PianoRoll.css';
|
||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
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 { DEBUG_MODE, PIANO_ROLL_CONSTANTS, TOOLBAR_CONSTANTS } from '../../constants';
|
||||||
import PianoRollHeader from './PianoRollHeader';
|
import PianoRollHeader from './PianoRollHeader';
|
||||||
import PianoRollToolbar from './PianoRollToolbar';
|
import PianoRollToolbar from './PianoRollToolbar';
|
||||||
|
import NoteAttributeBar from './NoteAttributeBar';
|
||||||
import PianoRollContent from './PianoRollContent';
|
import PianoRollContent from './PianoRollContent';
|
||||||
import { KGCore } from '../../core/KGCore';
|
import { KGCore } from '../../core/KGCore';
|
||||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||||
@@ -41,7 +42,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const isSpectrogram = mode === 'spectrogram';
|
const isSpectrogram = mode === 'spectrogram';
|
||||||
const isHybrid = mode === 'hybrid';
|
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
|
// Tool state for piano roll
|
||||||
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
||||||
@@ -73,6 +74,12 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const [activeRegion, setActiveRegion] = useState<KGMidiRegion | null>(null);
|
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 pianoRollRef = useRef<HTMLDivElement>(null);
|
||||||
const pianoRollContentRef = useRef<HTMLDivElement>(null);
|
const pianoRollContentRef = useRef<HTMLDivElement>(null);
|
||||||
const pianoGridRef = useRef<HTMLDivElement>(null);
|
const pianoGridRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -1019,6 +1026,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
onZoomChange={handleZoomChange}
|
onZoomChange={handleZoomChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<NoteAttributeBar selectedNotes={selectedNotes} isSpectrogram={isSpectrogram} activeRegion={activeRegion} />
|
||||||
|
|
||||||
<PianoRollContent
|
<PianoRollContent
|
||||||
contentRef={pianoRollContentRef}
|
contentRef={pianoRollContentRef}
|
||||||
pianoGridRef={pianoGridRef}
|
pianoGridRef={pianoGridRef}
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
top={parseFloat(tempStyle.top as string)}
|
top={parseFloat(tempStyle.top as string)}
|
||||||
width={parseFloat(tempStyle.width as string)}
|
width={parseFloat(tempStyle.width as string)}
|
||||||
height={noteHeight}
|
height={noteHeight}
|
||||||
|
velocity={note.getVelocity()}
|
||||||
onResizeStart={handleNoteResizeStart}
|
onResizeStart={handleNoteResizeStart}
|
||||||
onResize={handleNoteResize}
|
onResize={handleNoteResize}
|
||||||
onResizeEnd={handleNoteResizeEnd}
|
onResizeEnd={handleNoteResizeEnd}
|
||||||
@@ -221,7 +222,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PianoNote
|
<PianoNote
|
||||||
key={`note-${noteId}`}
|
key={`note-${noteId}`}
|
||||||
@@ -231,6 +232,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
top={top}
|
top={top}
|
||||||
width={width}
|
width={width}
|
||||||
height={noteHeight}
|
height={noteHeight}
|
||||||
|
velocity={note.getVelocity()}
|
||||||
onResizeStart={handleNoteResizeStart}
|
onResizeStart={handleNoteResizeStart}
|
||||||
onResize={handleNoteResize}
|
onResize={handleNoteResize}
|
||||||
onResizeEnd={handleNoteResizeEnd}
|
onResizeEnd={handleNoteResizeEnd}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export { DeleteNotesCommand, DeleteNoteCommand } from './note/DeleteNotesCommand
|
|||||||
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
export { ResizeNotesCommand } from './note/ResizeNotesCommand';
|
||||||
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
export { MoveNotesCommand } from './note/MoveNotesCommand';
|
||||||
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
export { PasteNotesCommand } from './note/PasteNotesCommand';
|
||||||
|
export { UpdateNotePropertiesCommand } from './note/UpdateNotePropertiesCommand';
|
||||||
|
|
||||||
// Project commands
|
// Project commands
|
||||||
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
export { ChangeProjectPropertyCommand, type ProjectUpdateProperties } from './project/ChangeProjectPropertyCommand';
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { KGCommand } from '../KGCommand';
|
||||||
|
import { KGCore } from '../../KGCore';
|
||||||
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
|
import { KGMidiRegion } from '../../region/KGMidiRegion';
|
||||||
|
import { KGTrack } from '../../track/KGTrack';
|
||||||
|
|
||||||
|
interface NoteSnapshot {
|
||||||
|
noteId: string;
|
||||||
|
pitch: number;
|
||||||
|
velocity: number;
|
||||||
|
startBeat: number;
|
||||||
|
endBeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoteUpdate {
|
||||||
|
noteId: string;
|
||||||
|
pitch?: number;
|
||||||
|
velocity?: number;
|
||||||
|
endBeat?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateNotePropertiesCommand extends KGCommand {
|
||||||
|
private regionId: string;
|
||||||
|
private snapshots: NoteSnapshot[];
|
||||||
|
private updates: NoteUpdate[];
|
||||||
|
private targetRegion: KGMidiRegion | null = null;
|
||||||
|
private parentTrack: KGTrack | null = null;
|
||||||
|
|
||||||
|
constructor(regionId: string, snapshots: NoteSnapshot[], updates: NoteUpdate[]) {
|
||||||
|
super();
|
||||||
|
this.regionId = regionId;
|
||||||
|
this.snapshots = [...snapshots];
|
||||||
|
this.updates = [...updates];
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(): void {
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const tracks = core.getCurrentProject().getTracks();
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
const region = track.getRegions().find(r => r.getId() === this.regionId) as KGMidiRegion | undefined;
|
||||||
|
if (region) {
|
||||||
|
this.targetRegion = region;
|
||||||
|
this.parentTrack = track;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.targetRegion) {
|
||||||
|
throw new Error(`Region with ID ${this.regionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = this.targetRegion.getNotes();
|
||||||
|
for (const update of this.updates) {
|
||||||
|
const note = notes.find((n: KGMidiNote) => n.getId() === update.noteId);
|
||||||
|
if (note) {
|
||||||
|
if (update.pitch !== undefined) note.setPitch(update.pitch);
|
||||||
|
if (update.velocity !== undefined) note.setVelocity(update.velocity);
|
||||||
|
if (update.endBeat !== undefined) note.setEndBeat(update.endBeat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
if (!this.targetRegion) {
|
||||||
|
throw new Error('Cannot undo: command was not executed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = this.targetRegion.getNotes();
|
||||||
|
for (const snap of this.snapshots) {
|
||||||
|
const note = notes.find((n: KGMidiNote) => n.getId() === snap.noteId);
|
||||||
|
if (note) {
|
||||||
|
note.setPitch(snap.pitch);
|
||||||
|
note.setVelocity(snap.velocity);
|
||||||
|
note.setStartBeat(snap.startBeat);
|
||||||
|
note.setEndBeat(snap.endBeat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
const count = this.snapshots.length;
|
||||||
|
return count === 1 ? 'Update note properties' : `Update ${count} notes' properties`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getParentTrack(): KGTrack | null {
|
||||||
|
return this.parentTrack;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user