Files
SonicForgeStudio/.kilo/plans/1785029911228-piano-roll-4-features.md
T

7.0 KiB

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;):

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:

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):

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 callbackdragccDragRef.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:

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:

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:

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:

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:

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):

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:

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:

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

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.