fix: đã sửa lỗi bị mất không hiển thị midi piano grid

This commit is contained in:
2026-07-26 10:05:23 +07:00
parent a765998455
commit a7417b9bfa
4 changed files with 533 additions and 305 deletions
@@ -0,0 +1,212 @@
# Piano Roll: 4 tính năng
## 1. Auto-scroll brush khi drag gần cạnh
**File**: `app/static/js/app.jsx`
**Vị trí**: Trong `handleGridMouseMove`, cuối block `draggedNote.mode === 'draw'` (trước `return;` ở dòng ~5237).
**Code thêm** (sau visitedPitches/brushIds logic, trước `return;`):
```js
const container = gridScrollRef.current;
if (container) {
const cr = container.getBoundingClientRect();
const edgeThreshold = 30;
const scrollStep = 6;
if (e.clientY < cr.top + edgeThreshold) {
container.scrollTop = Math.max(0, container.scrollTop - scrollStep);
} else if (e.clientY > cr.bottom - edgeThreshold) {
container.scrollTop = Math.min(container.scrollHeight - container.clientHeight, container.scrollTop + scrollStep);
}
}
```
**⚠ Edge case**: Nếu chuột dừng tại mép, `mousemove` ngưng → cuộn dừng. Để cuộn liên tục, dùng `setInterval` khi vào threshold. Tạm thời chấp nhập giới hạn này.
---
## 2. Ctrl+drag velocity với selected notes
**File**: `app/static/js/app.jsx`
### 2a. `handleCCMouseDown` (dòng ~5360)
Thay block `if (e.ctrlKey)` hiện tại:
```js
if (e.ctrlKey) {
if (selectedNoteIds.length > 0) {
selectedNoteIds.forEach(id => {
const idx = notes.findIndex(n => n.id === id);
if (idx !== -1) paintNote(idx, val);
});
ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: selectedNoteIds.map(id => notes.findIndex(n => n.id === id)).filter(i => i !== -1) };
} else {
if (noteIdx !== -1) paintNote(noteIdx, val);
ccDragRef.current = { active: true, lastBeat: beat, lastPainted: noteIdx !== -1 ? [noteIdx] : [] };
}
return;
}
```
### 2b. `handleCCMouseMove` (sau dòng ~5381)
Thêm block đầu `handleCCMouseMove` (SAU khi lấy `drag`, `painted`, TRƯỚC `candidateIdx`):
```js
if (drag.selectedMode && selectedNoteIds.length > 0) {
selectedNoteIds.forEach(id => {
const idx = notes.findIndex(n => n.id === id);
if (idx !== -1 && !painted.includes(idx)) {
setNotes(prev => prev.map((n, i) => {
if (i !== idx) return n;
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val };
}));
}
});
// Cập nhật lastPainted TRỰC TIẾP trên ref (không qua setNotes callback)
const newPainted = selectedNoteIds
.map(id => notes.findIndex(n => n.id === id))
.filter(i => i !== -1 && !painted.includes(i));
ccDragRef.current.lastPainted = [...painted, ...newPainted];
return;
}
```
**Không gán `drag.lastPainted` bên trong `setNotes` callback**`drag``ccDragRef.current`, gán trực tiếp vào ref ngoài callback để tránh stale closure.
### 2b2. Cleanup `selectedMode` khi mouseup (dòng ~5409)
Trong `handleCCMouseUp` (và `onMouseLeave`), thêm reset:
```js
if (ccDragRef.current) ccDragRef.current.selectedMode = false;
```
### 2c. CC canvas rendering (dòng ~4911-4932)
Thêm `isSelected` vào loop notes; đổi màu xanh dương `#3b82f6` khi selected:
```js
const isSelected = selectedNoteIds.includes(note.id);
// ...
ctx.strokeStyle = ccMode === 'pan' ? (isSelected ? '#60a5fa' : '#a78bfa') : (isSelected ? '#3b82f6' : '#fbbf24');
ctx.fillStyle = ccMode === 'pan' ? (isSelected ? '#3b82f6' : '#c084fc') : (isSelected ? '#3b82f6' : '#fbbf24');
```
Thêm `selectedNoteIds` vào dependency array của effect.
---
## 3. SNAP trong MIDI tab
**Ghi chú**: `getSnapBeat(beat, mode)` đã xử lý `mode === 'free'` bằng cách return `beat` không đổi. Không cần check `snapVal !== 'free'` riêng.
**File**: `app/static/js/app.jsx`
### 3a. Selection marquee — create (dòng ~5003-5008)
Snap `startBeat` khi tạo marquee:
```js
const snapStart = getSnapBeat(beat, snapVal);
setSelectionMarquee({
startBeat: snapStart, startPitch: pitch,
currentBeat: snapStart, currentPitch: pitch
});
```
### 3a2. Selection marquee — drag update (dòng ~5140-5144)
Snap `currentBeat` khi kéo marquee:
```js
const snappedBeat = getSnapBeat(beat, snapVal);
const marquee = {
...selectionMarquee,
currentBeat: snappedBeat,
currentPitch: pitch
};
```
### 3b. Ruler drag loop range (dòng ~5742-5763)
Snap `clickBeat` khởi tạo, snap `beat` trong onMove:
```js
const snappedStartBeat = getSnapBeat(clickBeat, snapVal);
const startData = { startX: e.clientX, startBeat: snappedStartBeat, scrollLeft: e.currentTarget.scrollLeft };
// ...
const rawBeat = Math.max(0, bx / pixelsPerBeat);
const beat = getSnapBeat(rawBeat, snapVal);
```
### 3c. Shift+Click ruler loop (dòng ~5732-5739)
Đổi `Math.round(clickBeat / 4) * 4` thành `getSnapBeat(clickBeat, snapVal)`:
```js
const beatSnap = getSnapBeat(clickBeat, snapVal);
```
### 3d. Ruler loop handles (dòng ~5791, ~5808, ~5829)
Đổi `Math.round(bx / pixelsPerBeat / 4) * 4` thành `getSnapBeat(bx / pixelsPerBeat, snapVal)` ở cả 3 handle (left resize, right resize, grab body).
---
## 4. Synth button trong MIDI tab toolbar
**File**: `app/static/js/app.jsx`
### 4a. Prop `onInstrumentSelect` (dòng ~4546)
Thêm `onInstrumentSelect` vào props destructuring.
### 4b. Button synth trong toolbar (dòng ~5662-5700)
Chèn button sau MIDI input select, trước transport buttons:
```jsx
React.createElement("button", {
onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId),
title: st.instrumentName || "Synth",
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[50px] ${st.instrumentName ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
}, React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), React.createElement("span", { className: "truncate text-[9px]" }, st.instrumentName || 'Synth'))
```
### 4c. Pass callback từ App (dòng ~15631-15652)
Thêm `onInstrumentSelect: (trackId) => { openInstrumentSelector(trackId); }`.
### 4d. Đồng bộ subTab instrument (dòng ~6343-6353)
Trong `setTrackInstrumentWithProgram`, thêm cập nhật `subTabs`:
```js
setSubTabs(prev => prev.map(s => {
if (s.trackId !== trackId) return s;
return { ...s, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName, instrumentId };
}));
```
---
## 5. Layout fix: thêm `h-full` (QUAN TRỌNG)
**File**: `app/static/js/app.jsx`, dòng ~5623
Đổi `className` của outer div từ:
```
"flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col"
```
thành:
```
"flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col h-full"
```
**Lý do**: `h-full` cung cấp height tham chiếu cho flex chain, tránh content area cao 0px.
---
## Thứ tự thực hiện
1. Sửa layout: thêm `h-full`
2. Feature 3: SNAP (4 edits nhỏ — dễ verify)
3. Feature 2: Velocity selected notes
4. Feature 1: Auto-scroll brush
5. Feature 4: Synth button (liên quan nhiều component nhất)
## Kiểm tra
```bash
cd /home/locpham/SonicForgeStudio && npm run build
```
Build phải pass. Nếu lỗi paren, kiểm tra đóng `()` tại cuối return statement.
+306 -291
View File
@@ -5657,306 +5657,321 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
return labels; return labels;
}; };
const beatSec = 60.0 / (parseInt(bpm) || 120); const beatSec = 60.0 / (parseInt(bpm) || 120);
const playHeadX = (st.currentTime || 0) / beatSec * pixelsPerBeat; const playHeadX = (st.currentTime || 0) / beatSec * pixelsPerBeat;
return React.createElement("div", { return React.createElement("div", {
className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col h-full" className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col h-full"
}, React.createElement("div", { },
className: "h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200" /* 1. TOOLBAR HEADER */
}, /*#__PURE__*/React.createElement("div", { React.createElement("div", {
className: "flex items-center gap-4" className: "h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200"
}, /*#__PURE__*/React.createElement("span", { }, React.createElement("div", {
className: "text-xs font-bold text-yellow-500 uppercase flex items-center gap-1.5" className: "flex items-center gap-4"
}, /*#__PURE__*/React.createElement("i", { }, React.createElement("span", {
"data-lucide": "music", className: "text-xs font-bold text-yellow-500 uppercase flex items-center gap-1.5"
className: "w-3.5 h-3.5" }, React.createElement("i", {
}), st.label), /*#__PURE__*/React.createElement("div", { "data-lucide": "music",
className: "flex items-center gap-1 text-xs" className: "w-3.5 h-3.5"
}, /*#__PURE__*/React.createElement("span", { }), st.label), React.createElement("div", {
className: "text-zinc-500 font-semibold" className: "flex items-center gap-1 text-xs"
}, "Snap to Scale"), /*#__PURE__*/React.createElement("button", { }, React.createElement("span", {
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, snapToScale: !(s.snapToScale !== undefined ? s.snapToScale : true) } : s)), className: "text-zinc-500 font-semibold"
className: `w-7 h-4 rounded-full transition-colors relative ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'bg-yellow-600' : 'bg-zinc-700'}`, }, "Snap to Scale"), React.createElement("button", {
style: { padding: 0 } onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, snapToScale: !(s.snapToScale !== undefined ? s.snapToScale : true) } : s)),
}, /*#__PURE__*/React.createElement("div", { className: `w-7 h-4 rounded-full transition-colors relative ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'bg-yellow-600' : 'bg-zinc-700'}`,
className: `w-3 h-3 rounded-full bg-white absolute top-0.5 transition-transform ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'translate-x-3.5' : 'translate-x-0.5'}` style: { padding: 0 }
}))), /*#__PURE__*/React.createElement("div", { }, React.createElement("div", {
className: "flex items-center gap-1 text-xs" className: `w-3 h-3 rounded-full bg-white absolute top-0.5 transition-transform ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'translate-x-3.5' : 'translate-x-0.5'}`
}, /*#__PURE__*/React.createElement("span", { }))), React.createElement("div", {
className: "text-zinc-500 font-semibold" className: "flex items-center gap-1 text-xs"
}, "Snap:"), /*#__PURE__*/React.createElement("select", { }, React.createElement("span", {
value: snapVal, className: "text-zinc-500 font-semibold"
onChange: e => setSnapVal(e.target.value), }, "Snap:"), React.createElement("select", {
className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500" value: snapVal,
}, ['free', '4', '1', '1/2', '1/4', '1/8', '1/16', '1/32'].map(v => /*#__PURE__*/React.createElement("option", { onChange: e => setSnapVal(e.target.value),
key: v, className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500"
value: v }, ['free', '4', '1', '1/2', '1/4', '1/8', '1/16', '1/32'].map(v => React.createElement("option", {
}, v)))), /*#__PURE__*/React.createElement("button", { key: v,
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, isArmed: !s.isArmed } : s)), value: v
className: `px-2 py-1 rounded text-xs font-bold ${st.isArmed ? 'bg-red-600 text-white' : 'bg-zinc-800 text-zinc-400'}` }, v)))), React.createElement("button", {
}, "ARM"), /*#__PURE__*/React.createElement("select", { onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, isArmed: !s.isArmed } : s)),
value: selectedMidiInputId || '', className: `px-2 py-1 rounded text-xs font-bold ${st.isArmed ? 'bg-red-600 text-white' : 'bg-zinc-800 text-zinc-400'}`
onChange: e => onMidiInputSelect(e.target.value), }, "ARM"), React.createElement("select", {
className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-xs outline-none max-w-[100px]" value: selectedMidiInputId || '',
}, /*#__PURE__*/React.createElement("option", { value: "" }, "Input"), /*#__PURE__*/React.createElement("option", { value: "ALL" }, "Omni"), (midiDevices || []).map(d => /*#__PURE__*/React.createElement("option", { key: d.id, value: d.id }, d.name || d.id))), /*#__PURE__*/React.createElement("button", { onChange: e => onMidiInputSelect(e.target.value),
onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId), className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-xs outline-none max-w-[100px]"
title: st.instrumentName || "Synth", }, React.createElement("option", { value: "" }, "Input"), React.createElement("option", { value: "ALL" }, "Omni"), (midiDevices || []).map(d => React.createElement("option", { key: d.id, value: d.id }, d.name || d.id))), React.createElement("button", {
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[50px] ${st.instrumentName ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}` onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId),
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), /*#__PURE__*/React.createElement("span", { className: "truncate text-[9px]" }, st.instrumentName || 'Synth')), /*#__PURE__*/React.createElement("div", { title: st.instrumentName || "Synth",
className: "flex items-center gap-0.5 ml-1" className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[50px] ${st.instrumentName ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
}, /*#__PURE__*/React.createElement("button", { }, React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), React.createElement("span", { className: "truncate text-[9px]" }, st.instrumentName || 'Synth')), React.createElement("div", {
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: Math.max(0, (st.currentTime || 0) - beatSec * 4) } : s)), className: "flex items-center gap-0.5 ml-1"
className: "w-6 h-6 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700", }, React.createElement("button", {
title: "Back 1 bar" onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: Math.max(0, (st.currentTime || 0) - beatSec * 4) } : s)),
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "step-back", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", { className: "w-6 h-6 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700",
onClick: onPlayPause, title: "Back 1 bar"
className: `w-6 h-6 flex items-center justify-center rounded border ${isPlaying ? 'bg-emerald-600 text-black' : 'bg-cyan-600 text-white'} border-cyan-500`, }, React.createElement("i", { "data-lucide": "step-back", className: "w-3 h-3" })), React.createElement("button", {
title: isPlaying ? "Pause" : "Play" onClick: onPlayPause,
}, /*#__PURE__*/React.createElement("i", { "data-lucide": isPlaying ? "pause" : "play", className: "w-3 h-3 fill-current" })), /*#__PURE__*/React.createElement("button", { className: `w-6 h-6 flex items-center justify-center rounded border ${isPlaying ? 'bg-emerald-600 text-black' : 'bg-cyan-600 text-white'} border-cyan-500`,
onClick: onStop, title: isPlaying ? "Pause" : "Play"
className: "w-6 h-6 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700", }, React.createElement("i", { "data-lucide": isPlaying ? "pause" : "play", className: "w-3 h-3 fill-current" })), React.createElement("button", {
title: "Stop" onClick: onStop,
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "square", className: "w-3 h-3 fill-current" })), /*#__PURE__*/React.createElement("button", { className: "w-6 h-6 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700",
onClick: onRecord, title: "Stop"
className: `w-6 h-6 flex items-center justify-center rounded border ${recordingState === 'RECORDING' ? 'bg-red-600 text-white border-red-500 animate-pulse' : 'bg-zinc-800 text-red-500 border-zinc-700'}` }, React.createElement("i", { "data-lucide": "square", className: "w-3 h-3 fill-current" })), React.createElement("button", {
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "circle", className: "w-3 h-3 fill-current" })), /*#__PURE__*/React.createElement("button", { onClick: onRecord,
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: (st.currentTime || 0) + beatSec * 4 } : s)), className: `w-6 h-6 flex items-center justify-center rounded border ${recordingState === 'RECORDING' ? 'bg-red-600 text-white border-red-500 animate-pulse' : 'bg-zinc-800 text-red-500 border-zinc-700'}`
className: "w-6 h-6 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700", }, React.createElement("i", { "data-lucide": "circle", className: "w-3 h-3 fill-current" })), React.createElement("button", {
title: "Forward 1 bar" onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: (st.currentTime || 0) + beatSec * 4 } : s)),
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "step-forward", className: "w-3 h-3" }))), /*#__PURE__*/React.createElement("div", { className: "w-6 h-6 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700",
className: "flex items-center gap-1 ml-1 text-xs" title: "Forward 1 bar"
}, /*#__PURE__*/React.createElement("span", { className: "text-zinc-500" }, "AI:"), /*#__PURE__*/React.createElement("input", { }, React.createElement("i", { "data-lucide": "step-forward", className: "w-3 h-3" }))), React.createElement("div", {
type: "number", value: aiBarStart, onChange: e => setAiBarStart(parseInt(e.target.value) || 0), className: "flex items-center gap-1 ml-1 text-xs"
className: "w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center" }, React.createElement("span", { className: "text-zinc-500" }, "AI:"), React.createElement("input", {
}), /*#__PURE__*/React.createElement("span", { className: "text-zinc-500" }, "-"), /*#__PURE__*/React.createElement("input", { type: "number", value: aiBarStart, onChange: e => setAiBarStart(parseInt(e.target.value) || 0),
type: "number", value: aiBarEnd, onChange: e => setAiBarEnd(parseInt(e.target.value) || 1), className: "w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center"
className: "w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center" }), React.createElement("span", { className: "text-zinc-500" }, "-"), React.createElement("input", {
}), /*#__PURE__*/React.createElement("span", { className: "text-zinc-500" }, "bar"), /*#__PURE__*/React.createElement("button", { type: "number", value: aiBarEnd, onChange: e => setAiBarEnd(parseInt(e.target.value) || 1),
onClick: () => { className: "w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center"
setIsLooping(!isLooping); }), React.createElement("span", { className: "text-zinc-500" }, "bar"), React.createElement("button", {
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, isLooping: !(s.isLooping || false) } : s)); onClick: () => {
}, setIsLooping(!isLooping);
className: `px-2 py-0.5 rounded text-xs ${isLooping ? 'bg-emerald-700 text-white border border-emerald-500' : 'bg-zinc-800 text-zinc-400'}` setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, isLooping: !(s.isLooping || false) } : s));
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "repeat", className: "w-3 h-3" }))), /*#__PURE__*/React.createElement("div", { },
className: "flex bg-zinc-800 p-0.5 rounded border border-zinc-700 text-xs" className: `px-2 py-0.5 rounded text-xs ${isLooping ? 'bg-emerald-700 text-white border border-emerald-500' : 'bg-zinc-800 text-zinc-400'}`
}, ['velocity', 'pan'].map(mode => /*#__PURE__*/React.createElement("button", { }, React.createElement("i", { "data-lucide": "repeat", className: "w-3 h-3" }))), React.createElement("div", {
key: mode, className: "flex bg-zinc-800 p-0.5 rounded border border-zinc-700 text-xs"
onClick: () => setCcMode(mode), }, ['velocity', 'pan'].map(mode => React.createElement("button", {
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'}` key: mode,
}, mode)))), /*#__PURE__*/React.createElement("button", { onClick: () => setCcMode(mode),
onClick: () => setShowCC(!showCC), 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'}`
className: `px-2 py-1 rounded text-xs ${showCC ? 'bg-purple-900/60 text-purple-300 border border-purple-700' : 'text-zinc-500 hover:text-zinc-300'}` }, mode)))), React.createElement("button", {
}, ccMode === 'pan' ? 'Pan' : 'Vel'), /*#__PURE__*/React.createElement("div", { onClick: () => setShowCC(!showCC),
className: "flex items-center gap-1" className: `px-2 py-1 rounded text-xs ${showCC ? 'bg-purple-900/60 text-purple-300 border border-purple-700' : 'text-zinc-500 hover:text-zinc-300'}`
}, /*#__PURE__*/React.createElement("button", { }, ccMode === 'pan' ? 'Pan' : 'Vel'), React.createElement("div", {
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes), className: "flex items-center gap-1"
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" }, React.createElement("button", {
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "Lưu"), /*#__PURE__*/React.createElement("button", { onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
onClick: onClose, 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"
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" }, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "Lưu"), React.createElement("button", {
}, /*#__PURE__*/React.createElement("i", { onClick: onClose,
"data-lucide": "x", 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"
className: "w-3 h-3" }, React.createElement("i", {
}), "Đóng"))), /*#__PURE__*/React.createElement("div", { "data-lucide": "x",
className: "h-6 bg-[#1a1a1f] border-b border-zinc-900 flex shrink-0" className: "w-3 h-3"
}, /*#__PURE__*/React.createElement("div", { }), "Đóng"))),
className: "w-[60px] bg-[#222222] border-r border-zinc-900 shrink-0"
}), /*#__PURE__*/React.createElement("div", { /* 2. BAR RULER (ĐÃ SỬA LẠI ĐÓNG NGOẶC ĐÚNG TẠI ĐÂY) */
ref: rulerScrollRef, React.createElement("div", {
className: "flex-1 overflow-hidden", className: "h-6 bg-[#1a1a1f] border-b border-zinc-900 flex shrink-0"
onMouseDown: (e) => { }, React.createElement("div", {
const rect = e.currentTarget.getBoundingClientRect(); className: "w-[60px] bg-[#222222] border-r border-zinc-900 shrink-0"
const x = e.clientX - rect.left + e.currentTarget.scrollLeft; }), React.createElement("div", {
const clickBeat = x / pixelsPerBeat; ref: rulerScrollRef,
const clickTime = clickBeat * beatSec; className: "flex-1 overflow-hidden",
if (clickTime >= 0) { onMouseDown: (e) => {
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime } : s)); const rect = e.currentTarget.getBoundingClientRect();
} const x = e.clientX - rect.left + e.currentTarget.scrollLeft;
if (e.shiftKey) { const clickBeat = x / pixelsPerBeat;
const beatSnap = getSnapBeat(clickBeat, snapVal); const clickTime = clickBeat * beatSec;
if (loopStartBeat === null) { if (clickTime >= 0) {
setLoopStartBeat(Math.max(0, beatSnap - 4)); setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime } : s));
setLoopEndBeat(Math.max(4, beatSnap));
} else {
setLoopEndBeat(Math.max(loopStartBeat + 4, beatSnap));
} }
return; if (e.shiftKey) {
} const beatSnap = getSnapBeat(clickBeat, snapVal);
const snappedStartBeat = getSnapBeat(clickBeat, snapVal); if (loopStartBeat === null) {
const startData = { startX: e.clientX, startBeat: snappedStartBeat, scrollLeft: e.currentTarget.scrollLeft }; setLoopStartBeat(Math.max(0, beatSnap - 4));
rulerDragRef.current = startData; setLoopEndBeat(Math.max(4, beatSnap));
const onMove = (ev) => { } else {
const r = rulerScrollRef.current; setLoopEndBeat(Math.max(loopStartBeat + 4, beatSnap));
if (!r || !rulerDragRef.current) return; }
const rRect = r.getBoundingClientRect(); return;
const bx = ev.clientX - rRect.left + rulerDragRef.current.scrollLeft;
const rawBeat = Math.max(0, bx / pixelsPerBeat);
const beat = getSnapBeat(rawBeat, snapVal);
if (Math.abs(ev.clientX - rulerDragRef.current.startX) > 5) {
const sBeat = Math.max(0, Math.min(rulerDragRef.current.startBeat, beat));
const eBeat = Math.max(sBeat + 1, Math.max(rulerDragRef.current.startBeat, beat));
setLoopStartBeat(sBeat);
setLoopEndBeat(eBeat);
setIsLooping(true);
setSubTabs(prev => prev.map(s => s.id === st.id ? {
...s,
selectionStart: sBeat * beatSec,
selectionEnd: eBeat * beatSec,
isLooping: true
} : s));
} }
}; const snappedStartBeat = getSnapBeat(clickBeat, snapVal);
const onUp = () => { rulerDragRef.current = null; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; const startData = { startX: e.clientX, startBeat: snappedStartBeat, scrollLeft: e.currentTarget.scrollLeft };
document.addEventListener('mousemove', onMove); rulerDragRef.current = startData;
document.addEventListener('mouseup', onUp); const onMove = (ev) => {
} const r = rulerScrollRef.current;
}, /*#__PURE__*/React.createElement("div", { if (!r || !rulerDragRef.current) return;
style: { const rRect = r.getBoundingClientRect();
width: `${viewWidth}px`, const bx = ev.clientX - rRect.left + rulerDragRef.current.scrollLeft;
height: '100%' const rawBeat = Math.max(0, bx / pixelsPerBeat);
}, const beat = getSnapBeat(rawBeat, snapVal);
className: "relative h-full font-mono text-[9px] text-zinc-500 font-bold" if (Math.abs(ev.clientX - rulerDragRef.current.startX) > 5) {
}, renderBarLabels(), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && /*#__PURE__*/React.createElement("div", { const sBeat = Math.max(0, Math.min(rulerDragRef.current.startBeat, beat));
style: { const eBeat = Math.max(sBeat + 1, Math.max(rulerDragRef.current.startBeat, beat));
left: `${loopStartBeat * pixelsPerBeat}px`, setLoopStartBeat(sBeat);
width: `${(loopEndBeat - loopStartBeat) * pixelsPerBeat}px`, setLoopEndBeat(eBeat);
top: 0, bottom: 0 setIsLooping(true);
}, setSubTabs(prev => prev.map(s => s.id === st.id ? {
className: "absolute bg-emerald-500/15 border-l border-r border-emerald-400" ...s,
}, /*#__PURE__*/React.createElement("div", { selectionStart: sBeat * beatSec,
style: { position: 'absolute', left: 0, top: 0, bottom: 0, width: '4px', cursor: 'ew-resize' }, selectionEnd: eBeat * beatSec,
onMouseDown: (e) => { isLooping: true
e.stopPropagation(); } : s));
const startBeat = loopStartBeat; }
const onMove = (ev) => { };
const r = rulerScrollRef.current; const onUp = () => { rulerDragRef.current = null; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
if (!r) return; document.addEventListener('mousemove', onMove);
const rRect = r.getBoundingClientRect(); document.addEventListener('mouseup', onUp);
const bx = ev.clientX - rRect.left + r.scrollLeft; }
const nBeat = Math.max(0, Math.min(loopEndBeat - 1, getSnapBeat(bx / pixelsPerBeat, snapVal))); }, React.createElement("div", {
setLoopStartBeat(nBeat); style: {
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: nBeat * beatSec } : s)); width: `${viewWidth}px`,
}; height: '100%'
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; },
document.addEventListener('mousemove', onMove); className: "relative h-full font-mono text-[9px] text-zinc-500 font-bold"
document.addEventListener('mouseup', onUp); }, renderBarLabels(), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && React.createElement("div", {
} style: {
}), /*#__PURE__*/React.createElement("div", { left: `${loopStartBeat * pixelsPerBeat}px`,
style: { position: 'absolute', right: 0, top: 0, bottom: 0, width: '4px', cursor: 'ew-resize' }, width: `${(loopEndBeat - loopStartBeat) * pixelsPerBeat}px`,
onMouseDown: (e) => { top: 0, bottom: 0
e.stopPropagation(); },
const onMove = (ev) => { className: "absolute bg-emerald-500/15 border-l border-r border-emerald-400"
const r = rulerScrollRef.current; }, React.createElement("div", {
if (!r) return; style: { position: 'absolute', left: 0, top: 0, bottom: 0, width: '4px', cursor: 'ew-resize' },
const rRect = r.getBoundingClientRect(); onMouseDown: (e) => {
const bx = ev.clientX - rRect.left + r.scrollLeft; e.stopPropagation();
const nBeat = Math.max(loopStartBeat + 1, getSnapBeat(bx / pixelsPerBeat, snapVal)); const startBeat = loopStartBeat;
setLoopEndBeat(nBeat); const onMove = (ev) => {
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionEnd: nBeat * beatSec } : s)); const r = rulerScrollRef.current;
}; if (!r) return;
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; const rRect = r.getBoundingClientRect();
document.addEventListener('mousemove', onMove); const bx = ev.clientX - rRect.left + r.scrollLeft;
document.addEventListener('mouseup', onUp); const nBeat = Math.max(0, Math.min(loopEndBeat - 1, getSnapBeat(bx / pixelsPerBeat, snapVal)));
} setLoopStartBeat(nBeat);
}), /*#__PURE__*/React.createElement("div", { setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: nBeat * beatSec } : s));
style: { position: 'absolute', left: '4px', right: '4px', top: 0, bottom: 0, cursor: 'grab' }, };
onMouseDown: (e) => { const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
e.stopPropagation(); document.addEventListener('mousemove', onMove);
const startBeat = loopStartBeat; document.addEventListener('mouseup', onUp);
const range = loopEndBeat - loopStartBeat; }
const offsetBeat = startBeat + range / 2; }), React.createElement("div", {
const onMove = (ev) => { style: { position: 'absolute', right: 0, top: 0, bottom: 0, width: '4px', cursor: 'ew-resize' },
const r = rulerScrollRef.current; onMouseDown: (e) => {
if (!r) return; e.stopPropagation();
const rRect = r.getBoundingClientRect(); const onMove = (ev) => {
const bx = ev.clientX - rRect.left + r.scrollLeft; const r = rulerScrollRef.current;
const centerBeat = getSnapBeat(bx / pixelsPerBeat, snapVal); if (!r) return;
const halfRange = range / 2; const rRect = r.getBoundingClientRect();
const newStart = Math.max(0, centerBeat - halfRange); const bx = ev.clientX - rRect.left + r.scrollLeft;
setLoopStartBeat(newStart); const nBeat = Math.max(loopStartBeat + 1, getSnapBeat(bx / pixelsPerBeat, snapVal));
setLoopEndBeat(newStart + range); setLoopEndBeat(nBeat);
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: newStart * beatSec, selectionEnd: (newStart + range) * beatSec } : s)); setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionEnd: nBeat * beatSec } : s));
}; };
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove); document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
} }
})))), /*#__PURE__*/React.createElement("div", { }), React.createElement("div", {
className: "flex-1 flex overflow-hidden min-h-0 relative" style: { position: 'absolute', left: '4px', right: '4px', top: 0, bottom: 0, cursor: 'grab' },
}, /*#__PURE__*/React.createElement("div", { onMouseDown: (e) => {
ref: keybedRef, e.stopPropagation();
onScroll: handleKeybedScroll, const startBeat = loopStartBeat;
className: "w-[60px] overflow-y-auto flex flex-col border-r border-zinc-900 shrink-0", const range = loopEndBeat - loopStartBeat;
style: { const offsetBeat = startBeat + range / 2;
scrollbarWidth: 'none', const onMove = (ev) => {
msOverflowStyle: 'none' const r = rulerScrollRef.current;
} if (!r) return;
}, renderKeybed()), /*#__PURE__*/React.createElement("div", { const rRect = r.getBoundingClientRect();
ref: gridScrollRef, const bx = ev.clientX - rRect.left + r.scrollLeft;
onScroll: handleScroll, const centerBeat = getSnapBeat(bx / pixelsPerBeat, snapVal);
className: "flex-1 overflow-auto bg-[#141414] min-w-0" const halfRange = range / 2;
}, /*#__PURE__*/React.createElement("div", { const newStart = Math.max(0, centerBeat - halfRange);
setLoopStartBeat(newStart);
setLoopEndBeat(newStart + range);
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: newStart * beatSec, selectionEnd: (newStart + range) * beatSec } : s));
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
}))))),
/* 3. MAIN PIANO ROLL GRID (KEYBOARD + CANVAS) */
React.createElement("div", {
className: "flex-1 flex overflow-hidden min-h-0 relative"
}, React.createElement("div", {
ref: keybedRef,
onScroll: handleKeybedScroll,
className: "w-[60px] overflow-y-auto flex flex-col border-r border-zinc-900 shrink-0",
style: {
scrollbarWidth: 'none',
msOverflowStyle: 'none'
}
}, renderKeybed()), React.createElement("div", {
ref: gridScrollRef,
onScroll: handleScroll,
className: "flex-1 overflow-auto bg-[#141414] min-w-0"
}, React.createElement("div", {
style: { style: {
width: `${viewWidth}px`, width: `${viewWidth}px`,
height: `${(128 - PITCH_START) * NoteHeight}px` height: `${(128 - PITCH_START) * NoteHeight}px`
}, },
className: "relative" className: "relative"
}, /*#__PURE__*/React.createElement("canvas", { }, React.createElement("canvas", {
ref: canvasRef, ref: canvasRef,
onMouseDown: handleGridMouseDown, onMouseDown: handleGridMouseDown,
onMouseMove: handleGridMouseMove, onMouseMove: handleGridMouseMove,
onMouseUp: handleGridMouseUp, onMouseUp: handleGridMouseUp,
onMouseLeave: handleGridMouseUp, onMouseLeave: handleGridMouseUp,
onContextMenu: handleContextMenu, onContextMenu: handleContextMenu,
className: "absolute inset-0 cursor-crosshair" className: "absolute inset-0 cursor-crosshair"
}), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && /*#__PURE__*/React.createElement("div", { }), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && React.createElement("div", {
style: { style: {
left: `${loopStartBeat * pixelsPerBeat}px`, left: `${loopStartBeat * pixelsPerBeat}px`,
width: `${(loopEndBeat - loopStartBeat) * pixelsPerBeat}px`, width: `${(loopEndBeat - loopStartBeat) * pixelsPerBeat}px`,
top: 0, bottom: 0 top: 0, bottom: 0
}, },
className: "absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none" className: "absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none"
})))), showCC && /*#__PURE__*/React.createElement("div", { })))),
style: { height: `${ccHeight}px` },
className: "bg-[#161616] border-t border-zinc-900 flex shrink-0 relative" /* 4. CC / VELOCITY LANE (ĐẶT Ở ĐÁY) */
}, /*#__PURE__*/React.createElement("div", { showCC && React.createElement("div", {
onMouseDown: e => { style: { height: `${ccHeight}px` },
e.preventDefault(); className: "bg-[#161616] border-t border-zinc-900 flex shrink-0 relative"
const startY = e.clientY; }, React.createElement("div", {
const startH = ccHeight; onMouseDown: e => {
const onMove = ev => { setCcHeight(Math.max(40, startH + startY - ev.clientY)); }; e.preventDefault();
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; const startY = e.clientY;
document.addEventListener('mousemove', onMove); const startH = ccHeight;
document.addEventListener('mouseup', onUp); const onMove = ev => { setCcHeight(Math.max(40, startH + startY - ev.clientY)); };
}, const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
className: "absolute top-0 left-0 right-0 h-1.5 cursor-n-resize z-10 hover:bg-purple-600/30" document.addEventListener('mousemove', onMove);
}), /*#__PURE__*/React.createElement("div", { document.addEventListener('mouseup', onUp);
className: "w-[60px] bg-[#222222] border-r border-zinc-900 flex items-center justify-center text-[10px] text-zinc-500 font-mono shrink-0 font-bold" },
}, ccMode.toUpperCase()), /*#__PURE__*/React.createElement("div", { className: "absolute top-0 left-0 right-0 h-1.5 cursor-n-resize z-10 hover:bg-purple-600/30"
ref: ccWrapperRef, }), React.createElement("div", {
className: "flex-1 overflow-x-hidden min-w-0" className: "w-[60px] bg-[#222222] border-r border-zinc-900 flex items-center justify-center text-[10px] text-zinc-500 font-mono shrink-0 font-bold"
}, /*#__PURE__*/React.createElement("div", { }, ccMode.toUpperCase()), React.createElement("div", {
style: { ref: ccWrapperRef,
width: `${viewWidth}px`, className: "flex-1 overflow-x-hidden min-w-0"
height: '100%' }, React.createElement("div", {
}, style: {
className: "relative" width: `${viewWidth}px`,
}, /*#__PURE__*/React.createElement("canvas", { height: '100%'
ref: ccCanvasRef, },
onMouseDown: handleCCMouseDown, className: "relative"
onMouseMove: handleCCMouseMove, }, React.createElement("canvas", {
onMouseUp: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; }, ref: ccCanvasRef,
onMouseLeave: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; }, onMouseDown: handleCCMouseDown,
className: "absolute inset-0" onMouseMove: handleCCMouseMove,
}), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && /*#__PURE__*/React.createElement("div", { onMouseUp: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; },
style: { onMouseLeave: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; },
left: `${loopStartBeat * pixelsPerBeat}px`, className: "absolute inset-0"
width: `${(loopEndBeat - loopStartBeat) * pixelsPerBeat}px`, }), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && React.createElement("div", {
top: 0, bottom: 0 style: {
}, left: `${loopStartBeat * pixelsPerBeat}px`,
className: "absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none" width: `${(loopEndBeat - loopStartBeat) * pixelsPerBeat}px`,
})))), scaleMenuPos && renderScaleContextMenu()); top: 0, bottom: 0
},
className: "absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none"
})))),
/* 5. OVERLAY / CONTEXT MENU */
scaleMenuPos && renderScaleContextMenu()
);
}; };
const serializeTracksList = (tracksList, secondsPerBar) => { const serializeTracksList = (tracksList, secondsPerBar) => {
File diff suppressed because one or more lines are too long
Binary file not shown.