IMPROVE: thay đổi giao diện của PIANO ROLL TAB

This commit is contained in:
2026-08-07 21:22:13 +07:00
parent 45ab31d42e
commit 652e745d0a
4 changed files with 129 additions and 95 deletions
+88 -89
View File
@@ -7115,24 +7115,26 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
// Shift+C chord stamp, Shift+S lock-scale toggle // Shift+C chord stamp, Shift+S lock-scale toggle
const inField = e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable); const inField = e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable);
// Ctrl+C/X/V copy/paste/cut NOTES (thay 2 nút Copy/Paste đã b // Ctrl+C/X/V copy/paste/cut NOTES (thay 2 nút Copy/Paste đã b
// user 08:40) // user 08:40) CH tác đng NOTES ĐƯC CHN (user 09:10)
if ((e.ctrlKey || e.metaKey) && !e.altKey && !inField) { if ((e.ctrlKey || e.metaKey) && !e.altKey && !inField) {
if (e.key === 'c') { if (e.key === 'c') {
e.preventDefault(); e.preventDefault();
if (notes && notes.length) { const sel = notes.filter(n => selectedNoteIds.includes(n.id));
if (onCopyNotes) onCopyNotes(notes); if (sel.length) {
showToast('Đã copy ' + notes.length + ' nốt vào clipboard.', 'success'); if (onCopyNotes) onCopyNotes(sel);
} else { showToast('Không có nốt để copy.', 'warning'); } showToast('Đã copy ' + sel.length + ' nốt được chọn.', 'success');
} else { showToast('Chọn notes trước khi copy (Ctrl+C).', 'warning'); }
return; return;
} }
if (e.key === 'x') { if (e.key === 'x') {
e.preventDefault(); e.preventDefault();
if (notes && notes.length) { const sel = notes.filter(n => selectedNoteIds.includes(n.id));
if (onCopyNotes) onCopyNotes(notes); if (sel.length) {
if (onCopyNotes) onCopyNotes(sel);
pushToUndo(notes); pushToUndo(notes);
setNotes([]); setNotes(prev => (prev || []).filter(n => !selectedNoteIds.includes(n.id)));
showToast('Đã cắt ' + notes.length + ' nốt.', 'success'); showToast('Đã cắt ' + sel.length + ' nốt được chọn.', 'success');
} else { showToast('Không có nốt để cắt.', 'warning'); } } else { showToast('Chọn notes trước khi cắt (Ctrl+X).', 'warning'); }
return; return;
} }
if (e.key === 'v') { if (e.key === 'v') {
@@ -7616,6 +7618,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
} }
}, [notes, snapValue, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes, showGhostNotes, sessionSyncMode, ghostLayers, renderBeatOffset, renderTick, activeTracks, focusItemId]); }, [notes, snapValue, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes, showGhostNotes, sessionSyncMode, ghostLayers, renderBeatOffset, renderTick, activeTracks, focusItemId]);
// showCC/ccHeight khai báo TRƯC useLayoutEffect v CC (deps tham chiếu
// khai báo sau TDZ error user 08:50)
const [showCC, setShowCC] = React.useState(true);
const [ccHeight, setCcHeight] = React.useState(80);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const canvas = ccCanvasRef.current; const canvas = ccCanvasRef.current;
if (!canvas) return; if (!canvas) return;
@@ -7659,7 +7666,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
}); });
}, [notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset]); }, [notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset, ccHeight, showCC]);
React.useEffect(() => { React.useEffect(() => {
const scrollToC3 = () => { const scrollToC3 = () => {
@@ -8539,8 +8546,6 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
snapToScaleRef.current = st.snapToScale !== undefined ? st.snapToScale : true; snapToScaleRef.current = st.snapToScale !== undefined ? st.snapToScale : true;
const [scaleMenuPos, setScaleMenuPos] = React.useState(null); const [scaleMenuPos, setScaleMenuPos] = React.useState(null);
const scaleMenuOriginRef = React.useRef(null); const scaleMenuOriginRef = React.useRef(null);
const [showCC, setShowCC] = React.useState(true);
const [ccHeight, setCcHeight] = React.useState(80);
const snapPitchToScale = (pitch, scale) => { const snapPitchToScale = (pitch, scale) => {
if (!scale) return pitch; if (!scale) return pitch;
@@ -8901,13 +8906,61 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
return showGhostNotes ? base + 'bg-purple-900/60 text-purple-300 border border-purple-700' : base + 'text-zinc-500 hover:text-zinc-300'; return showGhostNotes ? base + 'bg-purple-900/60 text-purple-300 border border-purple-700' : base + 'text-zinc-500 hover:text-zinc-300';
}(), }(),
title: "Toggle ghost notes visibility" title: "Toggle ghost notes visibility"
}, "👻 MIDI ghost notes"), React.createElement("button", { }, "👻 MIDI ghost notes"), React.createElement("div", {
onClick: onClose, className: "flex items-center gap-1 ml-auto"
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 ml-auto" }, React.createElement("button", {
}, React.createElement("i", { 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"
"data-lucide": "x", }, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "L\u01B0u"), React.createElement("button", {
className: "w-3 h-3" onClick: () => {
}), "Đóng"), React.createElement("div", { const ppq = 480;
const bpmNum = parseInt(bpm) || 120;
const ticksPerBeat = ppq;
const events = [];
(notes || []).forEach(n => {
const startTick = Math.round((n.start_beat || 0) * ticksPerBeat);
const durTick = Math.round((n.duration_beats || 1) * ticksPerBeat);
const pitch = n.pitch || 60;
const vel = Math.round((n.velocity || 0.8) * 127);
events.push({ tick: startTick, type: 'note_on', pitch, velocity: vel });
events.push({ tick: startTick + durTick, type: 'note_off', pitch, velocity: 0 });
});
events.sort((a, b) => a.tick - b.tick || (a.type === 'note_off' ? -1 : 1));
const writeVLQ = (bytes, v) => {
let val = Math.max(0, v);
const buf = [];
buf.push(val & 0x7F);
while (val > 0x7F) { val >>= 7; buf.push(0x80 | (val & 0x7F)); }
for (let i = buf.length - 1; i >= 0; i--) bytes.push(buf[i]);
};
const trackBytes = [];
let lastTick = 0;
events.forEach(ev => {
const delta = Math.max(0, ev.tick - lastTick);
writeVLQ(trackBytes, delta);
trackBytes.push(ev.type === 'note_on' ? 0x90 : 0x80, ev.pitch, ev.velocity);
lastTick = ev.tick;
});
writeVLQ(trackBytes, 0);
trackBytes.push(0xFF, 0x2F, 0x00);
const trackData = [0x4D, 0x54, 0x72, 0x6B];
const len = trackBytes.length;
trackData.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF);
trackData.push(...trackBytes);
const header = [0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01, (ppq >> 8) & 0xFF, ppq & 0xFF];
const all = header.concat(trackData);
const blob = new Blob([new Uint8Array(all)], { type: 'audio/midi' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (st.label || 'midi') + '.mid';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('Đã xuất file MIDI!', 'success');
},
className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export MIDI")), React.createElement("div", {
style: { flexBasis: "100%", height: 0 } style: { flexBasis: "100%", height: 0 }
}), React.createElement("button", { }), React.createElement("button", {
onClick: () => setArpModal({ pattern: 'UP', rate: '1/16', octaves: 2, gate: 80, triplet: false, dotted: false }), onClick: () => setArpModal({ pattern: 'UP', rate: '1/16', octaves: 2, gate: 80, triplet: false, dotted: false }),
@@ -8964,7 +9017,7 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
}, React.createElement("div", { }, React.createElement("div", {
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'}` 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'}`
}))), React.createElement("div", { }))), React.createElement("div", {
className: "flex items-center gap-1 text-xs" className: "flex items-center gap-1 text-sm"
}, React.createElement("span", { }, React.createElement("span", {
className: "text-zinc-500 font-semibold" className: "text-zinc-500 font-semibold"
}, "Scale:"), React.createElement("select", { }, "Scale:"), React.createElement("select", {
@@ -8974,7 +9027,7 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
setSelectedScale(v === 'null' ? null : JSON.parse(v)); setSelectedScale(v === 'null' ? null : JSON.parse(v));
setRenderTick(t => t + 1); setRenderTick(t => t + 1);
}, },
className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500 max-w-[110px]" className: "bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500 max-w-[110px]"
}, React.createElement("option", { value: "null" }, "None"), (function() { }, React.createElement("option", { value: "null" }, "None"), (function() {
const opts = []; const opts = [];
const pushKey = (label, val) => opts.push(React.createElement("option", { key: label, value: JSON.stringify(val) }, label)); const pushKey = (label, val) => opts.push(React.createElement("option", { key: label, value: JSON.stringify(val) }, label));
@@ -8988,105 +9041,51 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
}())), React.createElement("select", { }())), React.createElement("select", {
value: scaleRoot, value: scaleRoot,
onChange: e => { setScaleRoot(parseInt(e.target.value) || 0); setRenderTick(t => t + 1); }, onChange: e => { setScaleRoot(parseInt(e.target.value) || 0); setRenderTick(t => t + 1); },
className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500" className: "bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500"
}, ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'].map((r, i) => React.createElement("option", { key: r, value: i }, r))), React.createElement("button", { }, ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'].map((r, i) => React.createElement("option", { key: r, value: i }, r))), React.createElement("button", {
onClick: () => applyForceToScale(), onClick: () => applyForceToScale(),
title: "Force selected notes to scale", title: "Force selected notes to scale",
className: "px-1.5 py-0.5 rounded text-[10px] bg-zinc-800 text-amber-400 border border-zinc-700 hover:border-amber-600" className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-amber-400 border border-zinc-700 hover:border-amber-600"
}, "Force")), React.createElement("div", { }, "Force")), React.createElement("div", {
className: "flex items-center gap-1 text-xs" className: "flex items-center gap-1 text-sm"
}, React.createElement("span", { }, React.createElement("span", {
className: "text-zinc-500 font-semibold ml-1" className: "text-zinc-500 font-semibold ml-1"
}, "Chord:"), React.createElement("select", { }, "Chord:"), React.createElement("select", {
value: chordType, value: chordType,
onChange: e => setChordType(e.target.value), onChange: e => setChordType(e.target.value),
className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-[10px] outline-none" className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-sm outline-none"
}, React.createElement("option", { value: "triad" }, "Triad"), React.createElement("option", { value: "min7" }, "Min7"), React.createElement("option", { value: "sus4" }, "Sus4"), React.createElement("option", { value: "add9" }, "Add9")), React.createElement("button", { }, React.createElement("option", { value: "triad" }, "Triad"), React.createElement("option", { value: "min7" }, "Min7"), React.createElement("option", { value: "sus4" }, "Sus4"), React.createElement("option", { value: "add9" }, "Add9")), React.createElement("button", {
onClick: () => setChordStampMode(m => !m), onClick: () => setChordStampMode(m => !m),
title: "Chord stamp mode — click canvas to stamp chord (Shift+C)", title: "Chord stamp mode — click canvas to stamp chord (Shift+C)",
className: `px-1.5 py-0.5 rounded text-[10px] border ${chordStampMode ? 'bg-amber-800/60 text-amber-300 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:border-amber-600'}` className: `px-1.5 py-0.5 rounded text-sm border ${chordStampMode ? 'bg-amber-800/60 text-amber-300 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:border-amber-600'}`
}, "Stamp"), React.createElement("button", { }, "Stamp"), React.createElement("button", {
onClick: () => applyHarmonize(3), onClick: () => applyHarmonize(3),
title: "Harmonize +3rd", title: "Harmonize +3rd",
className: "px-1.5 py-0.5 rounded text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600" className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600"
}, "+3"), React.createElement("button", { }, "+3"), React.createElement("button", {
onClick: () => applyHarmonize(5), onClick: () => applyHarmonize(5),
title: "Harmonize +5th", title: "Harmonize +5th",
className: "px-1.5 py-0.5 rounded text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600" className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600"
}, "+5"), React.createElement("button", { }, "+5"), React.createElement("button", {
onClick: () => applyHarmonize(7), onClick: () => applyHarmonize(7),
title: "Harmonize +7th", title: "Harmonize +7th",
className: "px-1.5 py-0.5 rounded text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600" className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600"
}, "+7")), React.createElement("div", { }, "+7")), React.createElement("div", {
className: "flex items-center gap-1 text-xs" className: "flex items-center gap-1 text-sm"
}, React.createElement("span", { }, React.createElement("span", {
className: "text-zinc-500 font-semibold" className: "text-zinc-500 font-semibold"
}, "Vel:"), React.createElement("button", { }, "Vel:"), React.createElement("button", {
onClick: () => applyVelocityCompress(), onClick: () => applyVelocityCompress(),
title: "Compress velocity toward target", title: "Compress velocity toward target",
className: "px-1.5 py-0.5 rounded text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-purple-600" className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-purple-600"
}, "Comp"), React.createElement("input", { }, "Comp"), React.createElement("input", {
type: "number", value: velocityTarget, onChange: e => setVelocityTarget(parseInt(e.target.value) || 80), type: "number", value: velocityTarget, onChange: e => setVelocityTarget(parseInt(e.target.value) || 80),
className: "w-9 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-[10px] text-center" className: "w-14 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-sm text-center"
}), React.createElement("button", { }), React.createElement("button", {
onClick: () => applyVelocityNormalize(), onClick: () => applyVelocityNormalize(),
title: "Normalize (max → 127)", title: "Normalize (max → 127)",
className: "px-1.5 py-0.5 rounded text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-purple-600" className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-purple-600"
}, "Norm")), React.createElement("div", { }, "Norm"))),
className: "flex items-center gap-1 ml-auto"
}, 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"
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "L\u01B0u"), React.createElement("button", {
onClick: () => {
const ppq = 480;
const bpmNum = parseInt(bpm) || 120;
const ticksPerBeat = ppq;
const events = [];
(notes || []).forEach(n => {
const startTick = Math.round((n.start_beat || 0) * ticksPerBeat);
const durTick = Math.round((n.duration_beats || 1) * ticksPerBeat);
const pitch = n.pitch || 60;
const vel = Math.round((n.velocity || 0.8) * 127);
events.push({ tick: startTick, type: 'note_on', pitch, velocity: vel });
events.push({ tick: startTick + durTick, type: 'note_off', pitch, velocity: 0 });
});
events.sort((a, b) => a.tick - b.tick || (a.type === 'note_off' ? -1 : 1));
const writeVLQ = (bytes, v) => {
let val = Math.max(0, v);
const buf = [];
buf.push(val & 0x7F);
while (val > 0x7F) { val >>= 7; buf.push(0x80 | (val & 0x7F)); }
for (let i = buf.length - 1; i >= 0; i--) bytes.push(buf[i]);
};
const trackBytes = [];
let lastTick = 0;
events.forEach(ev => {
const delta = Math.max(0, ev.tick - lastTick);
writeVLQ(trackBytes, delta);
trackBytes.push(ev.type === 'note_on' ? 0x90 : 0x80, ev.pitch, ev.velocity);
lastTick = ev.tick;
});
writeVLQ(trackBytes, 0);
trackBytes.push(0xFF, 0x2F, 0x00);
const trackData = [0x4D, 0x54, 0x72, 0x6B];
const len = trackBytes.length;
trackData.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF);
trackData.push(...trackBytes);
const header = [0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01, (ppq >> 8) & 0xFF, ppq & 0xFF];
const all = header.concat(trackData);
const blob = new Blob([new Uint8Array(all)], { type: 'audio/midi' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (st.label || 'midi') + '.mid';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('Đã xuất file MIDI!', 'success');
},
className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export MIDI"))),
/* 2. BAR RULER */ /* 2. BAR RULER */
React.createElement("div", { React.createElement("div", {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -24,7 +24,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script> <script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608070840" defer></script> <script src="/static/js/app.precompiled.js?v=202608070910" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
+33
View File
@@ -2822,3 +2822,36 @@
(3) Ctrl+C/X/V trong PIANO ROLL TAB (keydown 7106): C = copy notes (onCopyNotes + toast), X = copy + xóa hết notes, V = paste clipboardNotes (append — như nút cũ) — deps thêm clipboardNotes (handler re-create khi clipboard đổi). (3) Ctrl+C/X/V trong PIANO ROLL TAB (keydown 7106): C = copy notes (onCopyNotes + toast), X = copy + xóa hết notes, V = paste clipboardNotes (append — như nút cũ) — deps thêm clipboardNotes (handler re-create khi clipboard đổi).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070840), `wiki.md`. Rebuild precompiled (build PASS). - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070840), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → toolbar 1 hàng (không còn Copy/Paste); ARP/STRUM/HUMANIZE + mức humanize hoạt động (mở modal); Ctrl+C → Ctrl+V paste notes; Ctrl+X cắt notes; Alt+A/S/R mở tool. - **Ghi chú/Test:** hard refresh → mở PIANO ROLL → toolbar 1 hàng (không còn Copy/Paste); ARP/STRUM/HUMANIZE + mức humanize hoạt động (mở modal); Ctrl+C → Ctrl+V paste notes; Ctrl+X cắt notes; Alt+A/S/R mở tool.
### [2026-08-07 08:45] Task: (1) Lưu + Export MIDI lên trước vị trí nút Đóng, bỏ Đóng (2) Velocity không cập nhật realtime khi đổi độ rộng/bật tắt thanh velocity
- **Yêu cầu user:** (1) mang 2 nút Lưu + Export MIDI lên trước nút Đóng + remove nút Đóng; (2) velocity note không cập nhật realtime khi thay đổi ccHeight (độ rộng) hoặc showCC (bật/tắt thanh velocity).
- **FIX (app.jsx PianoRollTabEditor):**
(1) Xóa nút "Đóng" (onClose) — chèn div ml-auto [Lưu + Export MIDI] (di chuyển từ cuối toolbar — xóa bản cũ); sửa cân bằng ngoặc (bản cũ `"Export MIDI")))` đóng cả toolbar div — thêm `)` tại `"Norm")))`).
(2) Effect vẽ CC canvas — thêm `ccHeight, showCC` vào deps (`[notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset, ccHeight, showCC]`) — đổi độ rộng/bật tắt → vẽ lại realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070845), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL toolbar: không còn nút Đóng; Lưu/Export ở cuối (trước hàng tool Scale/Chord/Vel); kéo đổi độ rộng thanh velocity / bấm Vel toggle → velocity bars cập nhật ngay.
### [2026-08-07 08:50] Task: FIX TDZ — ccHeight/showCC khai báo SAU effect vẽ CC
- **Lỗi user:** `Uncaught ReferenceError: Cannot access 'ccHeight' before initialization`.
- **Nguyên nhân:** 08:45 thêm ccHeight/showCC vào deps useLayoutEffect vẽ CC (7620) — NHƯNG 2 state khai báo ở 8542 (SAU effect) → deps array tham chiếu biến trước khai báo → TDZ error khi render.
- **FIX (app.jsx):** di chuyển `const [showCC, setShowCC]` + `const [ccHeight, setCcHeight]` LÊN TRƯỚC useLayoutEffect vẽ CC (7619) — xóa bản trùng cũ (8542).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070850), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → KHÔNG còn lỗi TDZ; đổi độ rộng thanh velocity / toggle Vel → vẽ lại realtime.
### [2026-08-07 08:55] Task: Tăng size tool từ "Snap to Scale" đến cuối hàng
- **Yêu cầu user:** tăng size các tool từ Snap to Scale đến cuối hàng (Snap to Scale toggle, Scale ▾, Root ▾, Force, Chord ▾, Stamp, +3/+5/+7, Vel: Comp/Norm).
- **FIX (app.jsx):** trong khối (Snap to Scale → Norm) — `text-[10px]``text-xs` (12px) — 9 chỗ (Force/Stamp/+3/+5/+7/Comp/Norm buttons + Chord select...).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070855), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL toolbar → các tool cuối hàng to hơn (text-xs).
### [2026-08-07 09:00] Task: Đồng bộ font hàng tool (Snap to Scale → Norm) lên text-sm
- **Yêu cầu user:** tăng kích thước font chữ trong hàng đó cho ĐỒNG BỘ.
- **FIX (app.jsx):** khối Snap to Scale → Norm — `text-xs``text-sm` (12 chỗ: 5 container div + buttons + Chord select + nhãn) + thêm `text-sm` vào 2 select Scale/Root (mặc định browser) — toàn hàng đồng bộ 14px.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070900), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL toolbar hàng tool — font đồng bộ text-sm (14px).
### [2026-08-07 09:05] Task: Tăng chiều rộng input target velocity (Vel Comp)
- **Yêu cầu user:** tăng chiều rộng scrollbox/input của Vel Comp để chứa đủ số velocity (127).
- **FIX (app.jsx):** input `velocityTarget` (bên cạnh nút Comp) — `w-9``w-14` (đủ 3 chữ số 0-127).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070905), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL → input target (cạnh Comp) rộng hơn, hiện đủ "127".