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
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
// 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.key === 'c') {
e.preventDefault();
if (notes && notes.length) {
if (onCopyNotes) onCopyNotes(notes);
showToast('Đã copy ' + notes.length + ' nốt vào clipboard.', 'success');
} else { showToast('Không có nốt để copy.', 'warning'); }
const sel = notes.filter(n => selectedNoteIds.includes(n.id));
if (sel.length) {
if (onCopyNotes) onCopyNotes(sel);
showToast('Đã copy ' + sel.length + ' nốt được chọn.', 'success');
} else { showToast('Chọn notes trước khi copy (Ctrl+C).', 'warning'); }
return;
}
if (e.key === 'x') {
e.preventDefault();
if (notes && notes.length) {
if (onCopyNotes) onCopyNotes(notes);
const sel = notes.filter(n => selectedNoteIds.includes(n.id));
if (sel.length) {
if (onCopyNotes) onCopyNotes(sel);
pushToUndo(notes);
setNotes([]);
showToast('Đã cắt ' + notes.length + ' nốt.', 'success');
} else { showToast('Không có nốt để cắt.', 'warning'); }
setNotes(prev => (prev || []).filter(n => !selectedNoteIds.includes(n.id)));
showToast('Đã cắt ' + sel.length + ' nốt được chọn.', 'success');
} else { showToast('Chọn notes trước khi cắt (Ctrl+X).', 'warning'); }
return;
}
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]);
// 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(() => {
const canvas = ccCanvasRef.current;
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.fill();
});
}, [notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset]);
}, [notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset, ccHeight, showCC]);
React.useEffect(() => {
const scrollToC3 = () => {
@@ -8539,8 +8546,6 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
snapToScaleRef.current = st.snapToScale !== undefined ? st.snapToScale : true;
const [scaleMenuPos, setScaleMenuPos] = React.useState(null);
const scaleMenuOriginRef = React.useRef(null);
const [showCC, setShowCC] = React.useState(true);
const [ccHeight, setCcHeight] = React.useState(80);
const snapPitchToScale = (pitch, scale) => {
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';
}(),
title: "Toggle ghost notes visibility"
}, "👻 MIDI ghost notes"), React.createElement("button", {
onClick: onClose,
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("i", {
"data-lucide": "x",
className: "w-3 h-3"
}), "Đóng"), React.createElement("div", {
}, "👻 MIDI ghost notes"), React.createElement("div", {
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")), React.createElement("div", {
style: { flexBasis: "100%", height: 0 }
}), React.createElement("button", {
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", {
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", {
className: "flex items-center gap-1 text-xs"
className: "flex items-center gap-1 text-sm"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold"
}, "Scale:"), React.createElement("select", {
@@ -8974,7 +9027,7 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
setSelectedScale(v === 'null' ? null : JSON.parse(v));
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() {
const opts = [];
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", {
value: scaleRoot,
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", {
onClick: () => applyForceToScale(),
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", {
className: "flex items-center gap-1 text-xs"
className: "flex items-center gap-1 text-sm"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold ml-1"
}, "Chord:"), React.createElement("select", {
value: chordType,
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", {
onClick: () => setChordStampMode(m => !m),
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", {
onClick: () => applyHarmonize(3),
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", {
onClick: () => applyHarmonize(5),
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", {
onClick: () => applyHarmonize(7),
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", {
className: "flex items-center gap-1 text-xs"
className: "flex items-center gap-1 text-sm"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold"
}, "Vel:"), React.createElement("button", {
onClick: () => applyVelocityCompress(),
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", {
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", {
onClick: () => applyVelocityNormalize(),
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"
}, "Norm")), React.createElement("div", {
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"))),
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"))),
/* 2. BAR RULER */
React.createElement("div", {