fix: thay đổi UX của section, piano roll

This commit is contained in:
2026-07-23 16:26:16 +07:00
parent b460d40824
commit 0b2382573f
4 changed files with 366 additions and 75 deletions
+279 -64
View File
@@ -3800,7 +3800,7 @@ const SystemManagerModal = ({
className: "px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-xs"
}, "Xóa")))))))));
};
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes }) => {
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs }) => {
const [activeRollTool, setActiveRollTool] = React.useState('select');
const [snapVal, setSnapVal] = React.useState('1/16');
const [ccMode, setCcMode] = React.useState('velocity');
@@ -3810,6 +3810,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const ccCanvasRef = React.useRef(null);
const gridScrollRef = React.useRef(null);
const keybedRef = React.useRef(null);
const rulerScrollRef = React.useRef(null);
const NoteHeight = 18;
const pixelsPerBeat = rollZoom;
@@ -3818,6 +3819,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const drawWidth = totalBeats * pixelsPerBeat;
const [notes, setNotes] = React.useState(st.notes || []);
const [selectedNoteIds, setSelectedNoteIds] = React.useState([]);
const [selectionMarquee, setSelectionMarquee] = React.useState(null); // { startBeat, startPitch, currentBeat, currentPitch }
const [draggedNote, setDraggedNote] = React.useState(null); // { mode: 'move'|'resize', idx, startOffsetBeat, originalStart }
const [hoveredResizeIdx, setHoveredResizeIdx] = React.useState(-1);
@@ -3868,6 +3871,53 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
};
}, []);
// Shift + Scroll event listener to adjust velocity
React.useEffect(() => {
const handleCanvasWheel = (e) => {
if (e.shiftKey) {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
// Find note under cursor
const noteUnderCursor = notes.find(n => {
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
});
const delta = e.deltaY < 0 ? 0.05 : -0.05;
if (noteUnderCursor) {
setNotes(prev => prev.map(n => {
if (n.id !== noteUnderCursor.id) return n;
const newVel = Math.max(0.1, Math.min(1.0, (n.velocity ?? 0.8) + delta));
return { ...n, velocity: newVel };
}));
} else if (selectedNoteIds.length > 0) {
setNotes(prev => prev.map(n => {
if (!selectedNoteIds.includes(n.id)) return n;
const newVel = Math.max(0.1, Math.min(1.0, (n.velocity ?? 0.8) + delta));
return { ...n, velocity: newVel };
}));
}
}
};
const canvas = canvasRef.current;
if (canvas) {
canvas.addEventListener('wheel', handleCanvasWheel, { passive: false });
}
return () => {
if (canvas) {
canvas.removeEventListener('wheel', handleCanvasWheel);
}
};
}, [notes, selectedNoteIds, pixelsPerBeat]);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -3913,26 +3963,48 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
ctx.stroke();
}
// Draw notes with velocity layer representation (Hin th layer velocity ngay trên nt)
// Draw notes with velocity layer representation
notes.forEach((note) => {
const x = note.start_beat * pixelsPerBeat;
const y = (127 - note.pitch) * NoteHeight;
const w = note.duration_beats * pixelsPerBeat;
const isSelected = selectedNoteIds.includes(note.id);
// Draw background of note (lighter yellow)
ctx.fillStyle = 'rgba(234, 179, 8, 0.25)';
ctx.strokeStyle = '#ca8a04';
ctx.lineWidth = 1;
// Draw background of note
ctx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.4)' : 'rgba(234, 179, 8, 0.25)';
ctx.strokeStyle = isSelected ? '#3b82f6' : '#ca8a04';
ctx.lineWidth = isSelected ? 1.5 : 1;
ctx.fillRect(x + 1, y + 1, w - 2, NoteHeight - 2);
ctx.strokeRect(x + 1, y + 1, w - 2, NoteHeight - 2);
// Draw velocity layer (solid yellow bar inside, proportional to velocity)
// Draw velocity layer (solid yellow/blue bar inside, proportional to velocity)
const vel = note.velocity !== undefined ? note.velocity : 0.8;
const velW = Math.max(2, (w - 2) * vel);
ctx.fillStyle = '#eab308';
ctx.fillStyle = isSelected ? '#3b82f6' : '#eab308';
ctx.fillRect(x + 1, y + 1, velW, NoteHeight - 2);
});
}, [notes, snapVal, rollZoom]);
// Draw selection marquee if active
if (selectionMarquee) {
const minBeat = Math.min(selectionMarquee.startBeat, selectionMarquee.currentBeat);
const maxBeat = Math.max(selectionMarquee.startBeat, selectionMarquee.currentBeat);
const minPitch = Math.min(selectionMarquee.startPitch, selectionMarquee.currentPitch);
const maxPitch = Math.max(selectionMarquee.startPitch, selectionMarquee.currentPitch);
const mx = minBeat * pixelsPerBeat;
const my = (127 - maxPitch) * NoteHeight;
const mw = (maxBeat - minBeat) * pixelsPerBeat;
const mh = (maxPitch - minPitch + 1) * NoteHeight;
ctx.fillStyle = 'rgba(59, 130, 246, 0.15)';
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.fillRect(mx, my, mw, mh);
ctx.strokeRect(mx, my, mw, mh);
ctx.setLineDash([]);
}
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee]);
React.useEffect(() => {
const canvas = ccCanvasRef.current;
@@ -3994,6 +4066,38 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
// Right click -> Quick delete note!
if (e.button === 2) {
e.preventDefault();
const clickedNote = notes.find(n => {
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
});
if (clickedNote) {
setNotes(prev => prev.filter(n => n.id !== clickedNote.id));
setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id));
showToast('Đã xóa nốt nhanh!', 'info');
}
return;
}
if (e.button !== 0) return; // Only handle left click
// Ctrl+Click -> Quick draw note!
if (e.ctrlKey) {
const start = getSnapBeat(beat, snapVal);
const newNote = {
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5),
pitch: pitch,
start_beat: start,
duration_beats: getSnapDuration(snapVal),
velocity: 0.8,
pan: 0.0
};
setNotes(prev => [...prev, newNote]);
showToast('Đã vẽ nhanh nốt mới!', 'info');
return;
}
if (hoveredResizeIdx !== -1) {
setDraggedNote({
mode: 'resize',
@@ -4007,43 +4111,41 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
});
if (activeRollTool === 'select') {
if (clickedNoteIdx !== -1) {
setDraggedNote({
mode: 'move',
idx: clickedNoteIdx,
startOffsetBeat: beat - notes[clickedNoteIdx].start_beat,
originalPitch: notes[clickedNoteIdx].pitch
});
} else {
const start = getSnapBeat(beat, snapVal);
const newNote = {
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5),
pitch: pitch,
start_beat: start,
duration_beats: getSnapDuration(snapVal),
velocity: 0.8,
pan: 0.0
};
setNotes(prev => [...prev, newNote]);
}
} else if (activeRollTool === 'eraser') {
if (clickedNoteIdx !== -1) {
setNotes(prev => prev.filter((_, idx) => idx !== clickedNoteIdx));
}
} else if (activeRollTool === 'pen') {
if (clickedNoteIdx === -1) {
const start = getSnapBeat(beat, snapVal);
const newNote = {
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5),
pitch: pitch,
start_beat: start,
duration_beats: getSnapDuration(snapVal),
velocity: 0.8,
pan: 0.0
};
setNotes(prev => [...prev, newNote]);
if (clickedNoteIdx !== -1) {
const clickedNote = notes[clickedNoteIdx];
// Selection management
if (!selectedNoteIds.includes(clickedNote.id)) {
if (e.shiftKey) {
setSelectedNoteIds(prev => [...prev, clickedNote.id]);
} else {
setSelectedNoteIds([clickedNote.id]);
}
}
const selectedNotesOffset = notes
.filter(n => selectedNoteIds.includes(n.id) || n.id === clickedNote.id)
.map(n => ({
id: n.id,
originalStartBeat: n.start_beat,
originalPitch: n.pitch
}));
setDraggedNote({
mode: 'move',
idx: clickedNoteIdx,
startOffsetBeat: beat - clickedNote.start_beat,
startOffsetPitch: pitch - clickedNote.pitch,
selectedNotesOffset: selectedNotesOffset
});
} else {
// Clicked in empty space -> Start selection marquee
setSelectedNoteIds([]);
setSelectionMarquee({
startBeat: beat,
startPitch: pitch,
currentBeat: beat,
currentPitch: pitch
});
}
};
@@ -4057,8 +4159,28 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
if (selectionMarquee) {
const marquee = {
...selectionMarquee,
currentBeat: beat,
currentPitch: pitch
};
setSelectionMarquee(marquee);
const minBeat = Math.min(marquee.startBeat, marquee.currentBeat);
const maxBeat = Math.max(marquee.startBeat, marquee.currentBeat);
const minPitch = Math.min(marquee.startPitch, marquee.currentPitch);
const maxPitch = Math.max(marquee.startPitch, marquee.currentPitch);
const insideIds = notes
.filter(n => n.start_beat >= minBeat && n.start_beat <= maxBeat && n.pitch >= minPitch && n.pitch <= maxPitch)
.map(n => n.id);
setSelectedNoteIds(insideIds);
return;
}
if (!draggedNote) {
// Check if mouse is near the right edge of any note
let foundIdx = -1;
for (let i = 0; i < notes.length; i++) {
const n = notes[i];
@@ -4090,15 +4212,18 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
duration_beats: newDuration
};
}));
} else {
const newStart = getSnapBeat(Math.max(0, beat - draggedNote.startOffsetBeat), snapVal);
const newPitch = Math.max(0, Math.min(127, pitch));
setNotes(prev => prev.map((n, idx) => {
if (idx !== draggedNote.idx) return n;
} else if (draggedNote.mode === 'move') {
const baseOriginal = draggedNote.selectedNotesOffset.find(o => o.id === notes[draggedNote.idx].id);
const deltaBeat = getSnapBeat(beat - draggedNote.startOffsetBeat, snapVal) - baseOriginal.originalStartBeat;
const deltaPitch = Math.round(pitch - draggedNote.startOffsetPitch) - baseOriginal.originalPitch;
setNotes(prev => prev.map(n => {
const offset = draggedNote.selectedNotesOffset.find(o => o.id === n.id);
if (!offset) return n;
return {
...n,
start_beat: newStart,
pitch: newPitch
start_beat: getSnapBeat(Math.max(0, offset.originalStartBeat + deltaBeat), snapVal),
pitch: Math.max(0, Math.min(127, offset.originalPitch + deltaPitch))
};
}));
}
@@ -4106,6 +4231,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const handleGridMouseUp = () => {
setDraggedNote(null);
setSelectionMarquee(null);
};
const handleContextMenu = (e) => {
e.preventDefault();
};
const handleCCMouseDown = (e) => {
@@ -4117,7 +4247,6 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const h = rect.height;
const beat = x / pixelsPerBeat;
// Find note near beat or closest note horizontally
let noteIdx = notes.findIndex(n => beat >= n.start_beat && beat <= n.start_beat + n.duration_beats);
if (noteIdx === -1) {
let minDistance = Infinity;
@@ -4155,10 +4284,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
keys.push(
/*#__PURE__*/React.createElement("div", {
key: pitch,
style: { height: `${NoteHeight}px` },
className: `w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition ${isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900' : 'bg-white text-zinc-800 border-r border-zinc-400'}`
}, showLabel && label)
key: pitch,
style: { height: `${NoteHeight}px` },
className: `w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition ${isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900' : 'bg-white text-zinc-800 border-r border-zinc-400'}`
}, showLabel && label)
);
}
return keys;
@@ -4168,6 +4297,29 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
if (keybedRef.current) {
keybedRef.current.scrollTop = e.currentTarget.scrollTop;
}
if (rulerScrollRef.current) {
rulerScrollRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};
const renderBarLabels = () => {
const labels = [];
const barsCount = Math.ceil(totalBeats / 4);
for (let bar = 0; bar < barsCount; bar++) {
const x = bar * 4 * pixelsPerBeat;
labels.push(
/*#__PURE__*/React.createElement("div", {
key: bar,
style: {
position: 'absolute',
left: `${x}px`,
top: '4px'
},
className: "pl-1 border-l border-zinc-700 h-full select-none"
}, `Bar ${bar}`)
);
}
return labels;
};
return /*#__PURE__*/React.createElement("div", {
@@ -4204,13 +4356,34 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
key: mode,
onClick: () => setCcMode(mode),
className: `px-2.5 py-1 rounded capitalize ${ccMode === mode ? 'bg-purple-900/60 text-purple-300 font-bold border border-purple-700' : 'text-zinc-400 hover:text-zinc-200'}`
}, mode)))), /*#__PURE__*/React.createElement("button", {
}, mode)))), /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-1"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "save",
className: "w-3 h-3"
}), "Lưu"), /*#__PURE__*/React.createElement("button", {
onClick: onClose,
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "x",
className: "w-3 h-3"
}), "Đóng")), /*#__PURE__*/React.createElement("div", {
}), "Đóng"))), /*#__PURE__*/React.createElement("div", {
className: "h-6 bg-[#1a1a1f] border-b border-zinc-900 flex shrink-0"
}, /*#__PURE__*/React.createElement("div", {
className: "w-[60px] bg-[#222222] border-r border-zinc-900 shrink-0"
}), /*#__PURE__*/React.createElement("div", {
ref: rulerScrollRef,
className: "flex-1 overflow-hidden"
}, /*#__PURE__*/React.createElement("div", {
style: {
width: `${drawWidth}px`,
height: '100%'
},
className: "relative h-full font-mono text-[9px] text-zinc-500 font-bold"
}, renderBarLabels()))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex overflow-hidden min-h-0 relative"
}, /*#__PURE__*/React.createElement("div", {
ref: keybedRef,
@@ -4235,6 +4408,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
onMouseMove: handleGridMouseMove,
onMouseUp: handleGridMouseUp,
onMouseLeave: handleGridMouseUp,
onContextMenu: handleContextMenu,
className: "absolute inset-0 cursor-crosshair"
})))), /*#__PURE__*/React.createElement("div", {
className: "h-20 bg-[#161616] border-t border-zinc-900 flex shrink-0"
@@ -5794,9 +5968,11 @@ const App = () => {
setActiveTab(tabId);
};
const handleUpdateMidiNotes = (tabId, trackId, midiItemId, updatedNotes) => {
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, notes: updatedNotes } : s));
const handleUpdateMidiNotes = (tabId, notes) => {
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, notes: notes } : s));
};
const handleSaveMidiNotes = (tabId, trackId, midiItemId, updatedNotes) => {
const isSectionTrack = sessionTabs.some(s => s.tracks.some(t => t.id === trackId));
if (isSectionTrack) {
setSessionTabs(prev => prev.map(s => {
@@ -5827,6 +6003,32 @@ const App = () => {
};
}));
}
showToast('Đã lưu các chỉnh sửa nốt MIDI vào item cha!', 'success');
};
const handleSaveSectionTab = (tabId) => {
const tab = sessionTabs.find(s => s.id === tabId);
if (!tab) return;
const bpmVal = parseInt(bpm) || 120;
const secondsPerBeat = 60.0 / bpmVal;
const secondsPerBar = secondsPerBeat * 4;
const durationSec = (tab.length_bars || 16.0) * secondsPerBar;
setTracks(prev => prev.map(t => {
if (!t.sections || t.sections.length === 0) return t;
return {
...t,
sections: t.sections.map(s => {
if (s.sectionId !== tab.sectionId) return s;
return {
...s,
name: tab.name,
duration: durationSec
};
})
};
}));
showToast(`Đã lưu nội dung Section "${tab.name}" vào Main Session!`, 'success');
};
// Double-click/Edit Section: open Main Session in new tab
@@ -11297,7 +11499,18 @@ const App = () => {
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "plus",
className: "w-3.5 h-3.5"
})), /*#__PURE__*/React.createElement("span", null, "Track")), /*#__PURE__*/React.createElement("div", {
})), /*#__PURE__*/React.createElement("span", null, "Track")), sessionTabs.some(s => s.id === activeTab) && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
}), /*#__PURE__*/React.createElement("button", {
onClick: () => handleSaveSectionTab(activeTab),
className: "px-2 py-1 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition",
title: "Lưu Section vào Main Session"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "save",
className: "w-3.5 h-3.5"
})), /*#__PURE__*/React.createElement("span", null, "Lưu Section"))), /*#__PURE__*/React.createElement("div", {
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
}), /*#__PURE__*/React.createElement("button", {
onClick: handleUndo,
@@ -12629,7 +12842,9 @@ const App = () => {
bpm: bpm,
viewportWidth: viewportWidth,
onClose: () => closeSubTab(st.id),
onUpdateNotes: handleUpdateMidiNotes
onUpdateNotes: handleUpdateMidiNotes,
onSaveNotes: handleSaveMidiNotes,
setSubTabs: setSubTabs
});
}
const subTrack = tracks.find(t => t.id === st.trackId);
File diff suppressed because one or more lines are too long
Binary file not shown.
+69
View File
@@ -0,0 +1,69 @@
# Kế hoạch nâng cấp chức năng Piano Roll, quản lý tab và cơ chế Lưu (Save) cho Section/MIDI items
Tài liệu này đề xuất các chỉnh sửa lớn cho hệ thống **Piano Roll****Section Tabs** để tăng trải nghiệm soạn nhạc (DAW workflow):
---
## Các yêu cầu chức năng cần bổ sung
### 1. Quản lý Tab & Tên Tab
- **Gán tên tab**: Khi mở Piano Roll, gán tên tab hiển thị là `Piano Roll: [Tên MIDI Item]` để khớp chính xác.
- **Không mất dữ liệu khi mở Section Tab**: Khi mở thêm Section tab mới, trạng thái và các nốt nhạc đang sửa đổi trong tab Piano Roll phải được giữ nguyên trong React state (`subTabs`), không bị xoá hay reset.
- **Tạo tab con cho Section Tab**: Nếu mở MIDI item thuộc một Section-tab, hệ thống sẽ tạo một tab Piano Roll tương ứng (nhận `parent_tab_id` là Section tab đó) để quản lý độc lập.
- **Mở lại tab cũ**: Khi kích hoạt chỉnh sửa MIDI item đã có tab Piano Roll mở sẵn, chuyển đổi `activeTab` về tab đó mà không tạo thêm tab mới trùng lặp.
### 2. Cơ chế Lưu (Save) thủ công
- **Lưu nốt MIDI**: Thay vì tự động cập nhật nốt nhạc thời gian thực về track gốc làm nặng ứng dụng, các chỉnh sửa nốt nhạc trong Piano Roll sẽ lưu cục bộ trong tab. Khi nhấn nút **"Lưu"** (Save) trên Piano Roll tab, nốt nhạc mới được cập nhật vào MIDI item cha.
- **Lưu Section Tab**: Bổ sung nút **"Lưu Section"** trên thanh công cụ của Section Tab. Khi bấm, tên và thời lượng (duration) của Section Tab sẽ cập nhật ngược lại vào block Section item ở **Main Session**.
### 3. Cải tiến tương tác Chuột trong Piano Roll
- **Ctrl + Click**: Vẽ nhanh 1 nốt nhạc mới tại tọa độ click (áp dụng snap).
- **Nhấp & Kéo (Click & Drag) ở khoảng trống**: Quét khung chọn (marquee selection) để chọn nhiều nốt cùng lúc.
- **Kéo cạnh phải nốt**: Thay đổi thời lượng nốt nhạc (Resize).
- **Kéo phần thân nốt**: Di chuyển nốt nhạc theo cao độ (pitch) và thời gian (beat). Nếu có nhiều nốt đang được chọn, di chuyển toàn bộ các nốt được chọn cùng nhau (multi-note move).
- **Shift + Cuộn chuột (Scroll)**:
- Nếu cuộn chuột trên 1 nốt nhạc: Tăng/giảm velocity của nốt đó (khoảng cách 0.05, giới hạn từ 0.1 đến 1.0).
- Nếu cuộn chuột ngoài khoảng trống khi có các nốt được chọn: Tăng/giảm velocity của toàn bộ các nốt đang được chọn.
- **Click chuột phải**: Xóa nhanh note
### 4. Thêm Thanh thước nhịp (Bar Ruler)
- Bổ sung thanh thước nhịp nằm phía trên lưới Piano Roll, hiển thị nhịp số bắt đầu từ `Bar 0`, `Bar 1`, `Bar 2`,...
- Thanh thước nhịp sẽ tự động cuộn ngang đồng bộ với lưới Piano Roll khi người dùng cuộn chuột ngang.
---
## Đề xuất thay đổi mã nguồn
### [app.jsx](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx)
#### A. Cấu trúc lại `handleUpdateMidiNotes` và thêm `handleSaveMidiNotes`
- Tách biệt cập nhật cục bộ và lưu trữ về MIDI item gốc.
#### B. Thêm nút "Lưu Section" và "Lưu MIDI Notes"
- Hiển thị nút **Lưu Section** trên thanh công cụ khi `activeTab` là Section Tab.
- Hiển thị nút **Lưu** màu xanh lá trên thanh điều khiển của `PianoRollTabEditor`.
#### C. Viết lại `PianoRollTabEditor` hỗ trợ sự kiện chuột
- Quản lý trạng thái `selectedNoteIds``selectionMarquee`.
- Thêm sự kiện `wheel` hỗ trợ `Shift+Scroll` thay đổi velocity.
- Vẽ khung chọn `selectionMarquee` trên canvas.
- Thêm component / thẻ div Bar Ruler phía trên canvas và đồng bộ hóa cuộn (`onScroll` cập nhật `rulerScrollRef.current.scrollLeft`).
---
## Kế hoạch xác minh
### Kiểm tra tự động
- Chạy `PYTHONPATH=. pytest` để đảm bảo logic lưu trữ phía Python không bị ảnh hưởng.
### Kiểm tra thủ công
1. **Kiểm tra tab & lưu trữ**:
- Bật Section Tab, tạo một MIDI item. Double-click để mở Piano Roll của nó.
- Sửa đổi nốt trong Piano Roll, chuyển qua lại giữa Main Session, Section Tab và Piano Roll. Xác minh nốt không bị mất.
- Bấm **Lưu** trong Piano Roll -> Nhìn ngoài timeline thấy MIDI item cập nhật nốt.
- Bấm **Lưu Section** -> Nhìn ngoài Main Session thấy Section item cập nhật tên/độ dài.
2. **Kiểm tra thao tác chuột**:
- Kiểm tra quét chuột chọn nhiều nốt, kéo di chuyển cả cụm.
- Kiểm tra `Ctrl+Click` vẽ nốt.
- Kiểm tra `Shift+Scroll` thay đổi độ đậm nhạt (velocity) của nốt.
- Kiểm tra thanh Bar Ruler hiển thị `Bar 0, Bar 1...` cuộn mượt mà.