feat(cc11): lane header CC dropdown + graph thread (2 fixed end nodes, preview-draw drag fix flicker)

This commit is contained in:
2026-08-26 11:48:13 +07:00
parent a800834c89
commit 74dccaf54a
2 changed files with 147 additions and 60 deletions
+126 -53
View File
@@ -9631,6 +9631,66 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const [showCC, setShowCC] = React.useState(true);
const [ccHeight, setCcHeight] = React.useState(80);
// Ve CC11 lane (graph thread). Tach rieng de drag ve preview truc tiep
// (truoc day setNotes tung mousemove + doc notes tu closure stale -> node
// cu/moi xen ke -> flicker). previewNoteId != null: chi ve note do voi
// previewCurve thay cho expression_curve da commit.
const paintCC11Lane = (previewNoteId, previewCurve) => {
const canvas = ccCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const h = ccHeight;
canvas.width = viewWidth * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#161616';
ctx.fillRect(0, 0, viewWidth, h);
ctx.strokeStyle = '#252525';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(viewWidth, h / 2);
ctx.stroke();
const yOf = v => h - ((v / 127) * (h - 20) + 10);
notes.forEach((note) => {
if (previewNoteId != null) {
if (note.id !== previewNoteId) return;
} else if (!selectedNoteIds.includes(note.id)) {
return;
}
const x0 = (renderBeatOffset + note.start_beat) * pixelsPerBeat;
const x1 = (renderBeatOffset + note.start_beat + (note.duration_beats || 1)) * pixelsPerBeat;
const curve = (previewNoteId != null && previewCurve ? previewCurve : (Array.isArray(note.expression_curve) ? note.expression_curve : [])).slice().sort((a, b) => a.position_ratio - b.position_ratio);
ctx.strokeStyle = '#34d399';
ctx.lineWidth = 2;
ctx.beginPath();
if (!curve.length) {
ctx.setLineDash([4, 4]);
ctx.moveTo(x0, yOf(127));
ctx.lineTo(x1, yOf(127));
ctx.setLineDash([]);
} else if (curve.length === 1) {
ctx.moveTo(x0, yOf(curve[0].value));
ctx.lineTo(x1, yOf(curve[0].value));
} else {
curve.forEach((p, i) => {
const x = x0 + p.position_ratio * (x1 - x0);
const y = yOf(p.value);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
}
ctx.stroke();
curve.forEach((p) => {
const x = x0 + p.position_ratio * (x1 - x0);
ctx.fillStyle = '#a7f3d0';
ctx.beginPath();
ctx.arc(x, yOf(p.value), 4, 0, 2 * Math.PI);
ctx.fill();
});
});
};
React.useLayoutEffect(() => {
const canvas = ccCanvasRef.current;
if (!canvas) return;
@@ -9652,41 +9712,15 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
ctx.stroke();
if (ccMode === 'cc11') {
// CC11 expression: curve anchors {position_ratio 0..1, value 0..127}
// ve cho note dang chon. Rong -> flat 127 (khong automation).
const yOf = v => h - ((v / 127) * (h - 20) + 10);
notes.forEach((note) => {
if (!selectedNoteIds.includes(note.id)) return;
const x0 = (renderBeatOffset + note.start_beat) * pixelsPerBeat;
const x1 = (renderBeatOffset + note.start_beat + (note.duration_beats || 1)) * pixelsPerBeat;
const curve = (Array.isArray(note.expression_curve) ? note.expression_curve : []).slice().sort((a, b) => a.position_ratio - b.position_ratio);
ctx.strokeStyle = '#34d399';
ctx.lineWidth = 2;
ctx.beginPath();
if (!curve.length) {
ctx.setLineDash([4, 4]);
ctx.moveTo(x0, yOf(127));
ctx.lineTo(x1, yOf(127));
ctx.setLineDash([]);
} else if (curve.length === 1) {
ctx.moveTo(x0, yOf(curve[0].value));
ctx.lineTo(x1, yOf(curve[0].value));
} else {
curve.forEach((p, i) => {
const x = x0 + p.position_ratio * (x1 - x0);
const y = yOf(p.value);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
}
ctx.stroke();
curve.forEach((p) => {
const x = x0 + p.position_ratio * (x1 - x0);
ctx.fillStyle = '#a7f3d0';
ctx.beginPath();
ctx.arc(x, yOf(p.value), 4, 0, 2 * Math.PI);
ctx.fill();
});
});
// CC11 expression: graph thread cho note dang chon; khi dang drag ve
// preview tu ccDragRef (khong phu thuoc state da commit).
const drag = ccDragRef.current;
if (drag && drag.mode === 'cc11' && drag.previewCurve) {
paintCC11Lane(drag.noteId, drag.previewCurve);
} else {
paintCC11Lane();
}
return;
} else {
notes.forEach((note) => {
const x = (renderBeatOffset + note.start_beat) * pixelsPerBeat;
@@ -10485,16 +10519,21 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const beat = x / pixelsPerBeat - renderBeatOffset;
const noteIdx = findCC11NoteAtBeat(beat);
if (noteIdx === -1) { ccDragRef.current = null; return; }
const note = notes[noteIdx];
const note = notesRef.current[noteIdx];
if (!note) return;
// Auto-select note duoc click: draw branch chi ve curve cho note duoc chon
// (selectedNoteIds) -> khong select -> anchor them nhung vo hinh (bug: khong ve duoc CC11)
if (note && note.id && !selectedNoteIds.includes(note.id)) {
if (note.id && !selectedNoteIds.includes(note.id)) {
setSelectedNoteIds(prev => [...prev, note.id]);
}
const dur = note.duration_beats || 1;
const ratio = Math.max(0, Math.min(1, (beat - note.start_beat) / dur));
const val = Math.max(0, Math.min(127, Math.round(((h - y) / h) * 127)));
const curve = getCC11Curve(note).slice();
// Curve rong -> khoi tao 2 node co dinh dau/cuoi note (graph thread)
if (!curve.length) {
curve.push({ position_ratio: 0, value: 127 }, { position_ratio: 1, value: 127 });
}
const x0 = (renderBeatOffset + note.start_beat) * pixelsPerBeat;
const x1 = x0 + dur * pixelsPerBeat;
const yOf = v => h - ((v / 127) * (h - 20) + 10);
@@ -10509,12 +10548,15 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (dup !== -1) { curve[dup] = { position_ratio: ratio, value: val }; anchorIdx = dup; }
else { curve.push({ position_ratio: ratio, value: val }); anchorIdx = curve.length - 1; }
} else {
curve[anchorIdx] = { position_ratio: ratio, value: val };
const cur = curve[anchorIdx];
// Node dau/cuoi co dinh: giu ratio 0/1, chi doi value
curve[anchorIdx] = (cur.position_ratio <= 0.001 || cur.position_ratio >= 0.999) ? { position_ratio: cur.position_ratio, value: val } : { position_ratio: ratio, value: val };
}
const updatedNotes = notes.map((n, i) => (i === noteIdx ? { ...n, expression_curve: curve } : n));
setNotes(updatedNotes);
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
ccDragRef.current = { mode: 'cc11', active: true, noteIdx, anchorIdx, noteStart: note.start_beat, noteDur: dur };
// Khong setNotes ngay (truoc day setNotes moi mousemove + doc notes tu
// closure stale -> node cu/moi xen ke -> flicker): ve preview truc tiep,
// commit vao notes chi khi mouseup/mouseleave.
ccDragRef.current = { mode: 'cc11', active: true, noteIdx, noteId: note.id, anchorIdx, noteStart: note.start_beat, noteDur: dur, previewCurve: curve };
paintCC11Lane(note.id, curve);
};
const handleCC11ContextMenu = (e) => {
if (ccMode !== 'cc11') return;
@@ -10542,11 +10584,26 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (d < delDist) { delDist = d; delIdx = i; }
});
if (delIdx === -1 || delDist > 8) return;
// Node dau/cuoi co dinh (graph thread) — khong cho xoa
if (curve[delIdx].position_ratio <= 0.001 || curve[delIdx].position_ratio >= 0.999) return;
curve.splice(delIdx, 1);
const updatedNotes = notes.map((n, i) => (i === noteIdx ? { ...n, expression_curve: curve } : n));
setNotes(updatedNotes);
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
};
// Commit CC11 preview khi mouseup/mouseleave — truoc day drag setNotes moi
// mousemove (flicker); gio chi cap nhat state 1 lan khi ket thuc drag.
const commitCC11Drag = () => {
const drag = ccDragRef.current;
if (!drag) return;
if (drag.mode === 'cc11' && drag.previewCurve) {
const curNotes = notesRef.current;
const updatedNotes = curNotes.map((n, i) => (i === drag.noteIdx ? { ...n, expression_curve: drag.previewCurve } : n));
setNotes(updatedNotes);
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
}
ccDragRef.current = null;
};
const handleCCMouseDown = (e) => {
if (ccMode === 'cc11') { handleCC11MouseDown(e); return; }
const canvas = ccCanvasRef.current;
@@ -10612,17 +10669,30 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const y = e.clientY - rect.top;
const h = rect.height;
const beat = x / pixelsPerBeat - renderBeatOffset;
const note = notes[drag.noteIdx];
const note = notesRef.current[drag.noteIdx];
if (!note) return;
const dur = drag.noteDur || note.duration_beats || 1;
const ratio = Math.max(0, Math.min(1, (beat - drag.noteStart) / dur));
const val = Math.max(0, Math.min(127, Math.round(((h - y) / h) * 127)));
const curve = getCC11Curve(note).slice();
const curve = (drag.previewCurve || getCC11Curve(note)).slice();
const idx = Math.min(drag.anchorIdx, curve.length - 1);
curve[idx] = { position_ratio: ratio, value: val };
const updatedNotes = notes.map((n, i) => (i === drag.noteIdx ? { ...n, expression_curve: curve } : n));
setNotes(updatedNotes);
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
const cur = curve[idx] || { position_ratio: ratio, value: val };
if (cur.position_ratio <= 0.001 || cur.position_ratio >= 0.999) {
// Node dau/cuoi co dinh: chi doi value, giu ratio 0/1
curve[idx] = { position_ratio: cur.position_ratio, value: val };
} else {
// Node giua: doi ca ratio+value, clamp giua 2 node lan can
let minR = 0, maxR = 1;
for (let i = 0; i < curve.length; i++) {
if (i === idx) continue;
const r = curve[i].position_ratio;
if (r < cur.position_ratio && r > minR) minR = r;
if (r > cur.position_ratio && r < maxR) maxR = r;
}
curve[idx] = { position_ratio: Math.min(maxR, Math.max(minR, ratio)), value: val };
}
drag.previewCurve = curve;
paintCC11Lane(drag.noteId, curve);
return;
}
const canvas = ccCanvasRef.current;
@@ -11657,9 +11727,12 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
className: "absolute top-0 left-0 right-0 h-1.5 cursor-n-resize z-10 hover:bg-purple-600/30"
}), React.createElement("div", {
className: "w-[120px] shrink-0"
}), React.createElement("div", {
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()), React.createElement("div", {
}), React.createElement("select", {
value: ccMode,
onChange: e => setCcMode(e.target.value),
title: "CC control",
className: "w-[60px] bg-[#222222] border-r border-zinc-900 text-[10px] text-zinc-200 font-mono shrink-0 font-bold text-center cursor-pointer outline-none"
}, React.createElement("option", { value: "velocity" }, "Vel"), React.createElement("option", { value: "sustain" }, "Sus"), React.createElement("option", { value: "modulation" }, "Mod"), React.createElement("option", { value: "pitch_bend" }, "Bend"), React.createElement("option", { value: "pan" }, "Pan"), React.createElement("option", { value: "cc11" }, "Expr")), React.createElement("div", {
ref: ccWrapperRef,
className: "flex-1 overflow-x-hidden min-w-0"
}, React.createElement("div", {
@@ -11673,8 +11746,8 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
onMouseDown: handleCCMouseDown,
onMouseMove: handleCCMouseMove,
onContextMenu: handleCC11ContextMenu,
onMouseUp: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; },
onMouseLeave: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; },
onMouseUp: commitCC11Drag,
onMouseLeave: commitCC11Drag,
className: "absolute inset-0"
}), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && React.createElement("div", {
style: {
File diff suppressed because one or more lines are too long