fix: thay đổi UX của section, piano roll

This commit is contained in:
2026-07-23 16:26:16 +07:00
parent b460d40824
commit 0b2382573f
4 changed files with 366 additions and 75 deletions
+279 -64
View File
@@ -3800,7 +3800,7 @@ const SystemManagerModal = ({
className: "px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-xs"
}, "Xóa")))))))));
};
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes }) => {
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs }) => {
const [activeRollTool, setActiveRollTool] = React.useState('select');
const [snapVal, setSnapVal] = React.useState('1/16');
const [ccMode, setCcMode] = React.useState('velocity');
@@ -3810,6 +3810,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const ccCanvasRef = React.useRef(null);
const gridScrollRef = React.useRef(null);
const keybedRef = React.useRef(null);
const rulerScrollRef = React.useRef(null);
const NoteHeight = 18;
const pixelsPerBeat = rollZoom;
@@ -3818,6 +3819,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const drawWidth = totalBeats * pixelsPerBeat;
const [notes, setNotes] = React.useState(st.notes || []);
const [selectedNoteIds, setSelectedNoteIds] = React.useState([]);
const [selectionMarquee, setSelectionMarquee] = React.useState(null); // { startBeat, startPitch, currentBeat, currentPitch }
const [draggedNote, setDraggedNote] = React.useState(null); // { mode: 'move'|'resize', idx, startOffsetBeat, originalStart }
const [hoveredResizeIdx, setHoveredResizeIdx] = React.useState(-1);
@@ -3868,6 +3871,53 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
};
}, []);
// Shift + Scroll event listener to adjust velocity
React.useEffect(() => {
const handleCanvasWheel = (e) => {
if (e.shiftKey) {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
// Find note under cursor
const noteUnderCursor = notes.find(n => {
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
});
const delta = e.deltaY < 0 ? 0.05 : -0.05;
if (noteUnderCursor) {
setNotes(prev => prev.map(n => {
if (n.id !== noteUnderCursor.id) return n;
const newVel = Math.max(0.1, Math.min(1.0, (n.velocity ?? 0.8) + delta));
return { ...n, velocity: newVel };
}));
} else if (selectedNoteIds.length > 0) {
setNotes(prev => prev.map(n => {
if (!selectedNoteIds.includes(n.id)) return n;
const newVel = Math.max(0.1, Math.min(1.0, (n.velocity ?? 0.8) + delta));
return { ...n, velocity: newVel };
}));
}
}
};
const canvas = canvasRef.current;
if (canvas) {
canvas.addEventListener('wheel', handleCanvasWheel, { passive: false });
}
return () => {
if (canvas) {
canvas.removeEventListener('wheel', handleCanvasWheel);
}
};
}, [notes, selectedNoteIds, pixelsPerBeat]);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -3913,26 +3963,48 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
ctx.stroke();
}
// Draw notes with velocity layer representation (Hin th layer velocity ngay trên nt)
// Draw notes with velocity layer representation
notes.forEach((note) => {
const x = note.start_beat * pixelsPerBeat;
const y = (127 - note.pitch) * NoteHeight;
const w = note.duration_beats * pixelsPerBeat;
const isSelected = selectedNoteIds.includes(note.id);
// Draw background of note (lighter yellow)
ctx.fillStyle = 'rgba(234, 179, 8, 0.25)';
ctx.strokeStyle = '#ca8a04';
ctx.lineWidth = 1;
// Draw background of note
ctx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.4)' : 'rgba(234, 179, 8, 0.25)';
ctx.strokeStyle = isSelected ? '#3b82f6' : '#ca8a04';
ctx.lineWidth = isSelected ? 1.5 : 1;
ctx.fillRect(x + 1, y + 1, w - 2, NoteHeight - 2);
ctx.strokeRect(x + 1, y + 1, w - 2, NoteHeight - 2);
// Draw velocity layer (solid yellow bar inside, proportional to velocity)
// Draw velocity layer (solid yellow/blue bar inside, proportional to velocity)
const vel = note.velocity !== undefined ? note.velocity : 0.8;
const velW = Math.max(2, (w - 2) * vel);
ctx.fillStyle = '#eab308';
ctx.fillStyle = isSelected ? '#3b82f6' : '#eab308';
ctx.fillRect(x + 1, y + 1, velW, NoteHeight - 2);
});
}, [notes, snapVal, rollZoom]);
// Draw selection marquee if active
if (selectionMarquee) {
const minBeat = Math.min(selectionMarquee.startBeat, selectionMarquee.currentBeat);
const maxBeat = Math.max(selectionMarquee.startBeat, selectionMarquee.currentBeat);
const minPitch = Math.min(selectionMarquee.startPitch, selectionMarquee.currentPitch);
const maxPitch = Math.max(selectionMarquee.startPitch, selectionMarquee.currentPitch);
const mx = minBeat * pixelsPerBeat;
const my = (127 - maxPitch) * NoteHeight;
const mw = (maxBeat - minBeat) * pixelsPerBeat;
const mh = (maxPitch - minPitch + 1) * NoteHeight;
ctx.fillStyle = 'rgba(59, 130, 246, 0.15)';
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.fillRect(mx, my, mw, mh);
ctx.strokeRect(mx, my, mw, mh);
ctx.setLineDash([]);
}
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee]);
React.useEffect(() => {
const canvas = ccCanvasRef.current;
@@ -3994,6 +4066,38 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
// Right click -> Quick delete note!
if (e.button === 2) {
e.preventDefault();
const clickedNote = notes.find(n => {
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
});
if (clickedNote) {
setNotes(prev => prev.filter(n => n.id !== clickedNote.id));
setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id));
showToast('Đã xóa nốt nhanh!', 'info');
}
return;
}
if (e.button !== 0) return; // Only handle left click
// Ctrl+Click -> Quick draw note!
if (e.ctrlKey) {
const start = getSnapBeat(beat, snapVal);
const newNote = {
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5),
pitch: pitch,
start_beat: start,
duration_beats: getSnapDuration(snapVal),
velocity: 0.8,
pan: 0.0
};
setNotes(prev => [...prev, newNote]);
showToast('Đã vẽ nhanh nốt mới!', 'info');
return;
}
if (hoveredResizeIdx !== -1) {
setDraggedNote({
mode: 'resize',
@@ -4007,43 +4111,41 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
});
if (activeRollTool === 'select') {
if (clickedNoteIdx !== -1) {
setDraggedNote({
mode: 'move',
idx: clickedNoteIdx,
startOffsetBeat: beat - notes[clickedNoteIdx].start_beat,
originalPitch: notes[clickedNoteIdx].pitch
});
} else {
const start = getSnapBeat(beat, snapVal);
const newNote = {
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5),
pitch: pitch,
start_beat: start,
duration_beats: getSnapDuration(snapVal),
velocity: 0.8,
pan: 0.0
};
setNotes(prev => [...prev, newNote]);
}
} else if (activeRollTool === 'eraser') {
if (clickedNoteIdx !== -1) {
setNotes(prev => prev.filter((_, idx) => idx !== clickedNoteIdx));
}
} else if (activeRollTool === 'pen') {
if (clickedNoteIdx === -1) {
const start = getSnapBeat(beat, snapVal);
const newNote = {
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5),
pitch: pitch,
start_beat: start,
duration_beats: getSnapDuration(snapVal),
velocity: 0.8,
pan: 0.0
};
setNotes(prev => [...prev, newNote]);
if (clickedNoteIdx !== -1) {
const clickedNote = notes[clickedNoteIdx];
// Selection management
if (!selectedNoteIds.includes(clickedNote.id)) {
if (e.shiftKey) {
setSelectedNoteIds(prev => [...prev, clickedNote.id]);
} else {
setSelectedNoteIds([clickedNote.id]);
}
}
const selectedNotesOffset = notes
.filter(n => selectedNoteIds.includes(n.id) || n.id === clickedNote.id)
.map(n => ({
id: n.id,
originalStartBeat: n.start_beat,
originalPitch: n.pitch
}));
setDraggedNote({
mode: 'move',
idx: clickedNoteIdx,
startOffsetBeat: beat - clickedNote.start_beat,
startOffsetPitch: pitch - clickedNote.pitch,
selectedNotesOffset: selectedNotesOffset
});
} else {
// Clicked in empty space -> Start selection marquee
setSelectedNoteIds([]);
setSelectionMarquee({
startBeat: beat,
startPitch: pitch,
currentBeat: beat,
currentPitch: pitch
});
}
};
@@ -4057,8 +4159,28 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
if (selectionMarquee) {
const marquee = {
...selectionMarquee,
currentBeat: beat,
currentPitch: pitch
};
setSelectionMarquee(marquee);
const minBeat = Math.min(marquee.startBeat, marquee.currentBeat);
const maxBeat = Math.max(marquee.startBeat, marquee.currentBeat);
const minPitch = Math.min(marquee.startPitch, marquee.currentPitch);
const maxPitch = Math.max(marquee.startPitch, marquee.currentPitch);
const insideIds = notes
.filter(n => n.start_beat >= minBeat && n.start_beat <= maxBeat && n.pitch >= minPitch && n.pitch <= maxPitch)
.map(n => n.id);
setSelectedNoteIds(insideIds);
return;
}
if (!draggedNote) {
// Check if mouse is near the right edge of any note
let foundIdx = -1;
for (let i = 0; i < notes.length; i++) {
const n = notes[i];
@@ -4090,15 +4212,18 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
duration_beats: newDuration
};
}));
} else {
const newStart = getSnapBeat(Math.max(0, beat - draggedNote.startOffsetBeat), snapVal);
const newPitch = Math.max(0, Math.min(127, pitch));
setNotes(prev => prev.map((n, idx) => {
if (idx !== draggedNote.idx) return n;
} else if (draggedNote.mode === 'move') {
const baseOriginal = draggedNote.selectedNotesOffset.find(o => o.id === notes[draggedNote.idx].id);
const deltaBeat = getSnapBeat(beat - draggedNote.startOffsetBeat, snapVal) - baseOriginal.originalStartBeat;
const deltaPitch = Math.round(pitch - draggedNote.startOffsetPitch) - baseOriginal.originalPitch;
setNotes(prev => prev.map(n => {
const offset = draggedNote.selectedNotesOffset.find(o => o.id === n.id);
if (!offset) return n;
return {
...n,
start_beat: newStart,
pitch: newPitch
start_beat: getSnapBeat(Math.max(0, offset.originalStartBeat + deltaBeat), snapVal),
pitch: Math.max(0, Math.min(127, offset.originalPitch + deltaPitch))
};
}));
}
@@ -4106,6 +4231,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const handleGridMouseUp = () => {
setDraggedNote(null);
setSelectionMarquee(null);
};
const handleContextMenu = (e) => {
e.preventDefault();
};
const handleCCMouseDown = (e) => {
@@ -4117,7 +4247,6 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const h = rect.height;
const beat = x / pixelsPerBeat;
// Find note near beat or closest note horizontally
let noteIdx = notes.findIndex(n => beat >= n.start_beat && beat <= n.start_beat + n.duration_beats);
if (noteIdx === -1) {
let minDistance = Infinity;
@@ -4155,10 +4284,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
keys.push(
/*#__PURE__*/React.createElement("div", {
key: pitch,
style: { height: `${NoteHeight}px` },
className: `w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition ${isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900' : 'bg-white text-zinc-800 border-r border-zinc-400'}`
}, showLabel && label)
key: pitch,
style: { height: `${NoteHeight}px` },
className: `w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition ${isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900' : 'bg-white text-zinc-800 border-r border-zinc-400'}`
}, showLabel && label)
);
}
return keys;
@@ -4168,6 +4297,29 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
if (keybedRef.current) {
keybedRef.current.scrollTop = e.currentTarget.scrollTop;
}
if (rulerScrollRef.current) {
rulerScrollRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
};
const renderBarLabels = () => {
const labels = [];
const barsCount = Math.ceil(totalBeats / 4);
for (let bar = 0; bar < barsCount; bar++) {
const x = bar * 4 * pixelsPerBeat;
labels.push(
/*#__PURE__*/React.createElement("div", {
key: bar,
style: {
position: 'absolute',
left: `${x}px`,
top: '4px'
},
className: "pl-1 border-l border-zinc-700 h-full select-none"
}, `Bar ${bar}`)
);
}
return labels;
};
return /*#__PURE__*/React.createElement("div", {
@@ -4204,13 +4356,34 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
key: mode,
onClick: () => setCcMode(mode),
className: `px-2.5 py-1 rounded capitalize ${ccMode === mode ? 'bg-purple-900/60 text-purple-300 font-bold border border-purple-700' : 'text-zinc-400 hover:text-zinc-200'}`
}, mode)))), /*#__PURE__*/React.createElement("button", {
}, mode)))), /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-1"
}, /*#__PURE__*/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"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "save",
className: "w-3 h-3"
}), "Lưu"), /*#__PURE__*/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"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "x",
className: "w-3 h-3"
}), "Đóng")), /*#__PURE__*/React.createElement("div", {
}), "Đóng"))), /*#__PURE__*/React.createElement("div", {
className: "h-6 bg-[#1a1a1f] border-b border-zinc-900 flex shrink-0"
}, /*#__PURE__*/React.createElement("div", {
className: "w-[60px] bg-[#222222] border-r border-zinc-900 shrink-0"
}), /*#__PURE__*/React.createElement("div", {
ref: rulerScrollRef,
className: "flex-1 overflow-hidden"
}, /*#__PURE__*/React.createElement("div", {
style: {
width: `${drawWidth}px`,
height: '100%'
},
className: "relative h-full font-mono text-[9px] text-zinc-500 font-bold"
}, renderBarLabels()))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex overflow-hidden min-h-0 relative"
}, /*#__PURE__*/React.createElement("div", {
ref: keybedRef,
@@ -4235,6 +4408,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
onMouseMove: handleGridMouseMove,
onMouseUp: handleGridMouseUp,
onMouseLeave: handleGridMouseUp,
onContextMenu: handleContextMenu,
className: "absolute inset-0 cursor-crosshair"
})))), /*#__PURE__*/React.createElement("div", {
className: "h-20 bg-[#161616] border-t border-zinc-900 flex shrink-0"
@@ -5794,9 +5968,11 @@ const App = () => {
setActiveTab(tabId);
};
const handleUpdateMidiNotes = (tabId, trackId, midiItemId, updatedNotes) => {
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, notes: updatedNotes } : s));
const handleUpdateMidiNotes = (tabId, notes) => {
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, notes: notes } : s));
};
const handleSaveMidiNotes = (tabId, trackId, midiItemId, updatedNotes) => {
const isSectionTrack = sessionTabs.some(s => s.tracks.some(t => t.id === trackId));
if (isSectionTrack) {
setSessionTabs(prev => prev.map(s => {
@@ -5827,6 +6003,32 @@ const App = () => {
};
}));
}
showToast('Đã lưu các chỉnh sửa nốt MIDI vào item cha!', 'success');
};
const handleSaveSectionTab = (tabId) => {
const tab = sessionTabs.find(s => s.id === tabId);
if (!tab) return;
const bpmVal = parseInt(bpm) || 120;
const secondsPerBeat = 60.0 / bpmVal;
const secondsPerBar = secondsPerBeat * 4;
const durationSec = (tab.length_bars || 16.0) * secondsPerBar;
setTracks(prev => prev.map(t => {
if (!t.sections || t.sections.length === 0) return t;
return {
...t,
sections: t.sections.map(s => {
if (s.sectionId !== tab.sectionId) return s;
return {
...s,
name: tab.name,
duration: durationSec
};
})
};
}));
showToast(`Đã lưu nội dung Section "${tab.name}" vào Main Session!`, 'success');
};
// Double-click/Edit Section: open Main Session in new tab
@@ -11297,7 +11499,18 @@ const App = () => {
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "plus",
className: "w-3.5 h-3.5"
})), /*#__PURE__*/React.createElement("span", null, "Track")), /*#__PURE__*/React.createElement("div", {
})), /*#__PURE__*/React.createElement("span", null, "Track")), sessionTabs.some(s => s.id === activeTab) && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
}), /*#__PURE__*/React.createElement("button", {
onClick: () => handleSaveSectionTab(activeTab),
className: "px-2 py-1 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition",
title: "Lưu Section vào Main Session"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "save",
className: "w-3.5 h-3.5"
})), /*#__PURE__*/React.createElement("span", null, "Lưu Section"))), /*#__PURE__*/React.createElement("div", {
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
}), /*#__PURE__*/React.createElement("button", {
onClick: handleUndo,
@@ -12629,7 +12842,9 @@ const App = () => {
bpm: bpm,
viewportWidth: viewportWidth,
onClose: () => closeSubTab(st.id),
onUpdateNotes: handleUpdateMidiNotes
onUpdateNotes: handleUpdateMidiNotes,
onSaveNotes: handleSaveMidiNotes,
setSubTabs: setSubTabs
});
}
const subTrack = tracks.find(t => t.id === st.trackId);
File diff suppressed because one or more lines are too long