feat: vẽ nhanh MIDI note
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
# Plan: TCP Resizable Width + Instrument Search Dropdown + Section Save Fix
|
||||
|
||||
## Task 1: User-Resizable TCP Width
|
||||
|
||||
**Files:** `app/static/js/app.jsx`
|
||||
|
||||
### Root Cause
|
||||
TCP containers are hardcoded `w-[320px]` (lines 14253, 14809). Components like Synth button, FX button, volume/pan sliders, input select overflow when content is wide.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
**1a — Add TCP width state**
|
||||
Add near line 6394 (near existing `rightSidebarWidth` state):
|
||||
```javascript
|
||||
const [tcpWidth, setTcpWidth] = useState(320);
|
||||
```
|
||||
|
||||
**1b — Add TCP resize handler**
|
||||
Add near line 6365 (near `startColResize`):
|
||||
```javascript
|
||||
const startTcpResize = e => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = tcpWidth;
|
||||
const onMove = ev => {
|
||||
const deltaX = ev.clientX - startX;
|
||||
const newWidth = Math.max(280, Math.min(600, startW + deltaX));
|
||||
setTcpWidth(newWidth);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
```
|
||||
|
||||
**1c — Replace `w-[320px]` with dynamic width in main TCP container (line 14253)**
|
||||
Change `className: "w-[320px] shrink-0 ..."` to `style: { width: tcpWidth + 'px', ... }`.
|
||||
|
||||
**1d — Replace `w-[320px]` with dynamic width in sub-tab TCP container (line 14809)**
|
||||
Same pattern as 1c.
|
||||
|
||||
**1e — Add resize handle (right edge of TCP)**
|
||||
Add a vertical resize handle bar on the right edge of both TCP containers. Pattern:
|
||||
```jsx
|
||||
React.createElement("div", {
|
||||
onMouseDown: startTcpResize,
|
||||
className: "absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-40 hover:bg-cyan-500/50 transition-colors",
|
||||
style: { right: 0 }
|
||||
})
|
||||
```
|
||||
|
||||
**1f — Ensure the main layout accommodates variable TCP width**
|
||||
The main timeline area should use `flex-1` so it fills remaining space. Verify existing layout handles this.
|
||||
|
||||
### Verification
|
||||
- Drag TCP right edge → width changes between 280px and 600px
|
||||
- Components fit properly at various widths
|
||||
- Timeline area fills remaining space
|
||||
- Works in section-tab view too
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Instrument Search Dropdown in TCP
|
||||
|
||||
**Files:** `app/static/js/app.jsx`
|
||||
|
||||
### Root Cause
|
||||
Current instrument selector is a modal overlay (lines 15661-15726) with no search/filter. Requires clicking Synth button → modal → scroll to find instrument.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
**2a — Add per-track dropdown open/close state**
|
||||
Add state:
|
||||
```javascript
|
||||
const [instrumentDropdownTrackId, setInstrumentDropdownTrackId] = useState(null);
|
||||
```
|
||||
This tracks which track's dropdown is open (null = all closed).
|
||||
|
||||
**2b — Add search query state**
|
||||
```javascript
|
||||
const [instrumentSearchQuery, setInstrumentSearchQuery] = useState('');
|
||||
```
|
||||
|
||||
**2c — Replace Synth button (top toolbar, line 14444-14448) with dropdown toggle**
|
||||
Convert the icon-only `<button>` into a container that:
|
||||
1. Shows current instrument name (truncated) + chevron-down icon when assigned
|
||||
2. Shows "Synth" + chevron-down icon when no instrument
|
||||
3. Click toggles `instrumentDropdownTrackId` for this track
|
||||
|
||||
**2d — Render the dropdown panel (conditional, below the button)**
|
||||
When `instrumentDropdownTrackId === track.id`, render a dropdown panel:
|
||||
```jsx
|
||||
React.createElement("div", {
|
||||
className: "absolute left-0 top-full mt-0.5 z-50 bg-[#1e1e1e] border border-zinc-700 rounded shadow-xl w-[220px] max-h-[300px] flex flex-col",
|
||||
onClick: e => e.stopPropagation()
|
||||
},
|
||||
// Search input
|
||||
React.createElement("input", {
|
||||
type: "text",
|
||||
placeholder: "Tìm nhạc cụ...",
|
||||
value: instrumentSearchQuery,
|
||||
onChange: e => setInstrumentSearchQuery(e.target.value),
|
||||
className: "w-full bg-black border-b border-zinc-700 px-2 py-1.5 text-xs text-zinc-200 outline-none"
|
||||
}),
|
||||
// Filtered list
|
||||
React.createElement("div", {
|
||||
className: "flex-1 overflow-y-auto"
|
||||
},
|
||||
// Filtered items from instrumentSelectorData
|
||||
// "None (Default Synth)" always shown first
|
||||
// Then filtered soundfonts
|
||||
// Then filtered VST instruments
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**2e — Filter logic**
|
||||
```javascript
|
||||
const filteredInstruments = useMemo(() => {
|
||||
if (!instrumentSelectorData) return { soundfonts: [], vst: [] };
|
||||
const q = instrumentSearchQuery.toLowerCase();
|
||||
return {
|
||||
soundfonts: (instrumentSelectorData.soundfonts || []).filter(sf =>
|
||||
(sf.display || sf.name || sf.id).toLowerCase().includes(q)
|
||||
),
|
||||
vst: (instrumentSelectorData.vst_instruments || []).filter(v =>
|
||||
(v.name || v.id).toLowerCase().includes(q)
|
||||
)
|
||||
};
|
||||
}, [instrumentSearchQuery, instrumentSelectorData]);
|
||||
```
|
||||
|
||||
**2f — Click outside to close**
|
||||
Add a global click handler that closes the dropdown when clicking outside.
|
||||
|
||||
**2g — Preload `instrumentSelectorData` on first TCP mount**
|
||||
Instead of only loading on `openInstrumentSelector`, preload `listPlugins()` when the first track renders (or on app mount).
|
||||
|
||||
**2h — Apply selection**
|
||||
On click of a dropdown item, call existing `setTrackInstrumentWithProgram` or `setTrackInstrument`. Close dropdown.
|
||||
|
||||
### Verification
|
||||
- Click Synth button → dropdown opens with search input focused
|
||||
- Type instrument name → list filters in real-time
|
||||
- Click instrument → dropdown closes, track assigned, Synth button shows name
|
||||
- Click outside → dropdown closes
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Section-Tab Save Fix — Replace Instead of Draw On Top
|
||||
|
||||
**Files:** `app/static/js/app.jsx`
|
||||
|
||||
### Root Cause
|
||||
`handleEditSectionInTab` (line 7178) clones ALL main-session tracks (empty) into the session-tab when `section.tracks` doesn't exist. `handleSaveSectionTab` (line 7156) saves ALL those empty tracks + edited ones into `s.tracks`. The rendering code (lines 696-791) draws ALL stored tracks inside the section box, creating a cluttered preview with empty/minimal tracks.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
**3a — Fix `handleEditSectionInTab` (line 7169) to only initialize relevant track**
|
||||
Change the fallback cloning (line 7178) from cloning ALL main tracks to creating a minimal set of tracks based on the section's parent track:
|
||||
```javascript
|
||||
const clonedTracks = section.tracks ? section.tracks : [{
|
||||
...tracks.find(tr => tr.id === trackId),
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: [],
|
||||
markers: [],
|
||||
isArmed: false,
|
||||
monitoringEnabled: true,
|
||||
instrumentId: null,
|
||||
instrumentProgram: undefined,
|
||||
instrumentName: null
|
||||
}];
|
||||
```
|
||||
This only clones the track that owns the section, not ALL main tracks.
|
||||
|
||||
**3b — Fix `handleSaveSectionTab` (line 7138) to filter non-empty tracks**
|
||||
After building the updated section, filter `tab.tracks` to only include tracks that have actual content:
|
||||
```javascript
|
||||
const contentTracks = tab.tracks.filter(t =>
|
||||
(t.clips && t.clips.length > 0) ||
|
||||
(t.midiItems && t.midiItems.length > 0)
|
||||
);
|
||||
```
|
||||
Store `tracks: contentTracks` instead of `tracks: tab.tracks`.
|
||||
|
||||
**3c — Improve section-item rendering (lines 694-791)**
|
||||
The rendering already draws waveform from clips and MIDI notes from midiItems. Ensure:
|
||||
- Waveform rendering for clips with `clip.buffer` is correct (already done at lines 720-739)
|
||||
- MIDI note colors are per-track-index (already done at line 783: `noteColors[trackIdx % noteColors.length]`)
|
||||
- Add a subtle track label inside each sub-track row so users can identify which track is which
|
||||
|
||||
**3d — Ensure waveform preview is properly sized**
|
||||
The section preview currently allocates `subTrackHeight = (height - 24) / maxSubTracks` for each sub-track (line 698). Verify this is sufficient for waveform + MIDI note rendering when there are 1-2 tracks (typical case).
|
||||
|
||||
### Verification
|
||||
- Open a section for editing → session-tab shows only the relevant track(s), not all main tracks
|
||||
- Add MIDI items, sound clips, soundfonts, FX to tracks
|
||||
- Save section → section-item shows waveform preview + MIDI note preview (replacing previous content, not appending)
|
||||
- Open section again → previous edits are loaded correctly
|
||||
- Multiple save cycles → no doubling of content
|
||||
- Waveform rendered as background, MIDI notes in distinct colors per track
|
||||
Reference in New Issue
Block a user