Compare commits
5 Commits
f3d48e8837
...
6545e1746e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6545e1746e | |||
| 97a53fe59a | |||
| 5a80657b2b | |||
| 9843a33a9b | |||
| 78b9358539 |
+578
-54
@@ -184,6 +184,9 @@ const WaveformLane = ({
|
||||
onContextMenu,
|
||||
onClipDragStart,
|
||||
onClipStretchStart,
|
||||
onSectionItemDragStart,
|
||||
onSectionItemResizeStart,
|
||||
onEditSectionInTab,
|
||||
onSelectionEdgeDragStart,
|
||||
setSelectedClipId,
|
||||
selectedClipId,
|
||||
@@ -440,6 +443,40 @@ const WaveformLane = ({
|
||||
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', drawWidth / 2, height / 2);
|
||||
}
|
||||
|
||||
// Draw sections
|
||||
const sections = track.sections || [];
|
||||
sections.forEach(sec => {
|
||||
const secStartLocal = sec.start * zoom - scrollLeft;
|
||||
const secWidth = sec.duration * zoom;
|
||||
if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return;
|
||||
ctx.fillStyle = sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)';
|
||||
ctx.fillRect(secStartLocal, 2, secWidth, height - 4);
|
||||
ctx.strokeStyle = sec.color || '#06b6d4';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(secStartLocal, 2, secWidth, height - 4);
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#e4e4e7';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.fillText(sec.name || 'Section', Math.max(secStartLocal + 4, 4), 14);
|
||||
});
|
||||
|
||||
// Draw MIDI items
|
||||
const midiItems = track.midiItems || [];
|
||||
midiItems.forEach(midi => {
|
||||
const midiStartLocal = midi.startTime * zoom - scrollLeft;
|
||||
const midiWidth = midi.duration * zoom;
|
||||
if (midiStartLocal + midiWidth < 0 || midiStartLocal > drawWidth) return;
|
||||
ctx.fillStyle = '#a78bfa33';
|
||||
ctx.fillRect(midiStartLocal, 2, midiWidth, height - 4);
|
||||
ctx.strokeStyle = '#a78bfa';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(midiStartLocal, 2, midiWidth, height - 4);
|
||||
ctx.fillStyle = '#c4b5fd';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14);
|
||||
});
|
||||
|
||||
// Selection highlight - local selection on this track
|
||||
if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
|
||||
const hlLeftLocal = localSelLeft * zoom - scrollLeft;
|
||||
@@ -521,6 +558,49 @@ const WaveformLane = ({
|
||||
canvasRef.current.style.cursor = 'ew-resize';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check section/MIDI item hover for resize or drag
|
||||
const allSections = track.sections || [];
|
||||
const allMidiItems = track.midiItems || [];
|
||||
const sectionTolerance = 8 / zoom;
|
||||
let foundSectionItem = null;
|
||||
let sectionItemEdge = null;
|
||||
const checkEdge = (item, startTime, dur) => {
|
||||
const leftEdge = Math.abs(time - startTime) <= sectionTolerance;
|
||||
const rightEdge = Math.abs(time - (startTime + dur)) <= sectionTolerance;
|
||||
if (leftEdge || rightEdge) return leftEdge ? 'left' : 'right';
|
||||
return null;
|
||||
};
|
||||
for (const sec of allSections) {
|
||||
const edge = checkEdge(sec, sec.start, sec.duration);
|
||||
if (edge) { foundSectionItem = { type: 'section', item: sec }; sectionItemEdge = edge; break; }
|
||||
}
|
||||
if (!foundSectionItem) {
|
||||
for (const midi of allMidiItems) {
|
||||
const edge = checkEdge(midi, midi.startTime, midi.duration);
|
||||
if (edge) { foundSectionItem = { type: 'midiItem', item: midi }; sectionItemEdge = edge; break; }
|
||||
}
|
||||
}
|
||||
if (foundSectionItem && sectionItemEdge) {
|
||||
canvasRef.current.style.cursor = 'ew-resize';
|
||||
return;
|
||||
}
|
||||
// Check body hover for drag
|
||||
if (!foundSectionItem) {
|
||||
for (const sec of allSections) {
|
||||
if (time >= sec.start && time < sec.start + sec.duration) { foundSectionItem = { type: 'section', item: sec }; break; }
|
||||
}
|
||||
}
|
||||
if (!foundSectionItem) {
|
||||
for (const midi of allMidiItems) {
|
||||
if (time >= midi.startTime && time < midi.startTime + midi.duration) { foundSectionItem = { type: 'midiItem', item: midi }; break; }
|
||||
}
|
||||
}
|
||||
if (foundSectionItem) {
|
||||
canvasRef.current.style.cursor = 'grab';
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
const isOverClip = !!hoveredClip;
|
||||
if (activeTool === 'pen') {
|
||||
@@ -613,6 +693,46 @@ const WaveformLane = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check section/MIDI item edge for resize, then body for drag
|
||||
const secItems = track.sections || [];
|
||||
const midiItems = track.midiItems || [];
|
||||
const secTol = 8 / zoom;
|
||||
let hitItem = null;
|
||||
let hitEdge = null;
|
||||
for (const sec of secItems) {
|
||||
if (Math.abs(time - sec.start) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'left'; break; }
|
||||
if (Math.abs(time - (sec.start + sec.duration)) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'right'; break; }
|
||||
}
|
||||
if (!hitItem) {
|
||||
for (const midi of midiItems) {
|
||||
if (Math.abs(time - midi.startTime) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'left'; break; }
|
||||
if (Math.abs(time - (midi.startTime + midi.duration)) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'right'; break; }
|
||||
}
|
||||
}
|
||||
if (hitItem && hitEdge) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onSectionItemResizeStart) onSectionItemResizeStart(track.id, hitItem.type, hitItem.id, hitEdge, time);
|
||||
return;
|
||||
}
|
||||
if (!hitItem) {
|
||||
for (const sec of secItems) {
|
||||
if (time >= sec.start && time < sec.start + sec.duration) { hitItem = { type: 'section', id: sec.id, start: sec.start }; break; }
|
||||
}
|
||||
}
|
||||
if (!hitItem) {
|
||||
for (const midi of midiItems) {
|
||||
if (time >= midi.startTime && time < midi.startTime + midi.duration) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime }; break; }
|
||||
}
|
||||
}
|
||||
if (hitItem && !e.altKey && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start);
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
|
||||
// Set selected clip ID
|
||||
@@ -695,6 +815,18 @@ const WaveformLane = ({
|
||||
name: track.name,
|
||||
speed: track.speed || 1.0
|
||||
}] : [];
|
||||
// Check double-click on section first
|
||||
const dblSecItems = track.sections || [];
|
||||
let dblSecHit = null;
|
||||
for (const sec of dblSecItems) {
|
||||
if (time >= sec.start && time < sec.start + sec.duration) { dblSecHit = sec; break; }
|
||||
}
|
||||
if (dblSecHit) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onEditSectionInTab) onEditSectionInTab(track.id, dblSecHit.id);
|
||||
return;
|
||||
}
|
||||
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
if (clickedClip) {
|
||||
e.preventDefault();
|
||||
@@ -711,7 +843,13 @@ const WaveformLane = ({
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + (scrollLeft || 0);
|
||||
const time = Math.max(0, x / zoom);
|
||||
if (onContextMenu) onContextMenu(e, track.id, time);
|
||||
// Detect section under cursor
|
||||
const secList = track.sections || [];
|
||||
let hitSectionId = null;
|
||||
for (const sec of secList) {
|
||||
if (time >= sec.start && time < sec.start + sec.duration) { hitSectionId = sec.id; break; }
|
||||
}
|
||||
if (onContextMenu) onContextMenu(e, track.id, time, hitSectionId);
|
||||
}
|
||||
}));
|
||||
};
|
||||
@@ -3384,7 +3522,9 @@ const App = () => {
|
||||
color: '#0f766e',
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
clips: []
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}, {
|
||||
id: '2',
|
||||
name: 'Track 02',
|
||||
@@ -3397,7 +3537,10 @@ const App = () => {
|
||||
solo: false,
|
||||
color: '#1d4ed8',
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}]);
|
||||
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
|
||||
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
|
||||
@@ -3521,6 +3664,8 @@ const App = () => {
|
||||
const [menuOpen, setMenuOpen] = useState(null);
|
||||
const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId }
|
||||
const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap }
|
||||
const [draggedSectionItem, setDraggedSectionItem] = useState(null);
|
||||
const [resizedSectionItem, setResizedSectionItem] = useState(null);
|
||||
const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited
|
||||
const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId }
|
||||
const [editNameInput, setEditNameInput] = useState('');
|
||||
@@ -3601,6 +3746,11 @@ const App = () => {
|
||||
const [subTabGainVal, setSubTabGainVal] = useState(100);
|
||||
const [subTabPitchVal, setSubTabPitchVal] = useState(0);
|
||||
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
|
||||
const [sessionTabs, setSessionTabs] = useState([]); // [{id, name, tracks}, ...]
|
||||
const activeTracks = useMemo(() => {
|
||||
const st = sessionTabs.find(s => s.id === activeTab);
|
||||
return st ? st.tracks : tracks;
|
||||
}, [activeTab, sessionTabs, tracks]);
|
||||
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||
|
||||
|
||||
@@ -4567,6 +4717,27 @@ const App = () => {
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
// ── Double-click/Edit Section: open Main Session in new tab ──
|
||||
const handleEditSectionInTab = (trackId, sectionId) => {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (!track) return;
|
||||
const section = (track.sections || []).find(s => s.id === sectionId);
|
||||
if (!section) return;
|
||||
const existing = sessionTabs.find(s => s.sectionId === sectionId);
|
||||
if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; }
|
||||
const tabId = 'session_' + Date.now();
|
||||
const tabName = section.name || 'Section';
|
||||
const clonedTracks = tracks.map(t => ({
|
||||
...t,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: [],
|
||||
markers: []
|
||||
}));
|
||||
setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]);
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
|
||||
const applySubTab = tabId => {
|
||||
const subTab = subTabs.find(s => s.id === tabId);
|
||||
@@ -4902,6 +5073,10 @@ const App = () => {
|
||||
setSubTabs(prev => prev.filter(s => s.id !== tabId));
|
||||
if (activeTab === tabId) setActiveTab('main');
|
||||
};
|
||||
const closeSessionTab = tabId => {
|
||||
setSessionTabs(prev => prev.filter(s => s.id !== tabId));
|
||||
if (activeTab === tabId) setActiveTab('main');
|
||||
};
|
||||
const updateSubTabEffects = (tabId, effects) => {
|
||||
setSubTabs(prev => prev.map(s => s.id === tabId ? {
|
||||
...s,
|
||||
@@ -4947,18 +5122,26 @@ const App = () => {
|
||||
};
|
||||
|
||||
// ── Context Menu Handlers ──
|
||||
const handleContextMenu = (e, trackId, clickTime) => {
|
||||
const handleContextMenu = (e, trackId, clickTime, sectionId) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
trackId,
|
||||
time: clickTime || currentTime
|
||||
time: clickTime || currentTime,
|
||||
sectionId: sectionId || null
|
||||
});
|
||||
};
|
||||
const closeContextMenu = () => setContextMenu(null);
|
||||
|
||||
const contextMenuEditSection = () => {
|
||||
if (contextMenu.sectionId) {
|
||||
handleEditSectionInTab(contextMenu.trackId, contextMenu.sectionId);
|
||||
}
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
// Close context menu on any click outside
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
@@ -4993,15 +5176,27 @@ const App = () => {
|
||||
};
|
||||
const contextMenuDelete = () => {
|
||||
const tid = contextMenu.trackId;
|
||||
const beforeSnap = captureTrackSnapshot(tid);
|
||||
setTracks(prev => prev.filter(t => t.id !== tid));
|
||||
const afterSnap = captureTrackSnapshot(tid);
|
||||
pushAction('DELETE', tid, beforeSnap, afterSnap);
|
||||
if (selectedTrackId === tid) {
|
||||
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
||||
if (sessionTab) {
|
||||
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
|
||||
if (selectedTrackId === tid) setSelectedTrackId('1');
|
||||
closeContextMenu();
|
||||
showToast('Đã xoá track.', 'info');
|
||||
} else {
|
||||
const track = tracks.find(t => t.id === tid);
|
||||
if (track && track.sections && track.sections.length > 0) {
|
||||
closeContextMenu();
|
||||
showToast('Không thể xoá track chứa Section item.', 'warning');
|
||||
return;
|
||||
}
|
||||
const beforeSnap = captureTrackSnapshot(tid);
|
||||
setTracks(prev => prev.filter(t => t.id !== tid));
|
||||
const afterSnap = captureTrackSnapshot(tid);
|
||||
pushAction('DELETE', tid, beforeSnap, afterSnap);
|
||||
if (selectedTrackId === tid) setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
closeContextMenu();
|
||||
showToast('Đã xoá track.', 'info');
|
||||
}
|
||||
closeContextMenu();
|
||||
showToast('Đã xoá track.', 'info');
|
||||
};
|
||||
const contextMenuCopy = () => {
|
||||
const track = tracks.find(t => t.id === contextMenu.trackId);
|
||||
@@ -5375,8 +5570,20 @@ const App = () => {
|
||||
};
|
||||
const handleDeleteTrack = () => {
|
||||
const tid = selectedTrackId;
|
||||
setTracks(p => p.filter(t => t.id !== tid));
|
||||
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
||||
if (sessionTab) {
|
||||
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
|
||||
setSelectedTrackId(activeTracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
} else {
|
||||
const curTracks = tracks;
|
||||
const track = curTracks.find(t => t.id === tid);
|
||||
if (track && track.sections && track.sections.length > 0) {
|
||||
showToast('Không thể xoá track chứa Section item.', 'warning');
|
||||
return;
|
||||
}
|
||||
setTracks(p => p.filter(t => t.id !== tid));
|
||||
setSelectedTrackId(curTracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
}
|
||||
showToast('Deleted track.', 'info');
|
||||
};
|
||||
|
||||
@@ -6107,6 +6314,10 @@ const App = () => {
|
||||
hoveredTrackIdRef.current = hoveredTrackId;
|
||||
const captureTrackSnapshotRef = useRef(null);
|
||||
captureTrackSnapshotRef.current = captureTrackSnapshot;
|
||||
const draggedSectionItemRef = useRef(null);
|
||||
draggedSectionItemRef.current = draggedSectionItem;
|
||||
const resizedSectionItemRef = useRef(null);
|
||||
resizedSectionItemRef.current = resizedSectionItem;
|
||||
let clipSeqCounter = 0;
|
||||
const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`;
|
||||
|
||||
@@ -6228,14 +6439,26 @@ const App = () => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
const deleteTrack = trackId => {
|
||||
const beforeSnap = captureTrackSnapshot(trackId);
|
||||
setTracks(prev => {
|
||||
const filtered = prev.filter(t => t.id !== trackId);
|
||||
if (filtered.length > 0) {
|
||||
setSelectedTrackId(filtered[0].id);
|
||||
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
||||
if (sessionTab) {
|
||||
updateActiveTracks(prev => {
|
||||
const filtered = prev.filter(t => t.id !== trackId);
|
||||
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
|
||||
return filtered;
|
||||
});
|
||||
} else {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (track && track.sections && track.sections.length > 0) {
|
||||
showToast('Không thể xoá track chứa Section item.', 'warning');
|
||||
return;
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
const beforeSnap = captureTrackSnapshot(trackId);
|
||||
setTracks(prev => {
|
||||
const filtered = prev.filter(t => t.id !== trackId);
|
||||
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
showToast('Đã xóa track.', 'info');
|
||||
};
|
||||
useEffect(() => {
|
||||
@@ -6369,6 +6592,123 @@ const App = () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoom]);
|
||||
|
||||
// ── Section / MIDI Item Drag Start ──
|
||||
const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset) => {
|
||||
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset });
|
||||
};
|
||||
|
||||
// ── Section / MIDI Item Resize Start ──
|
||||
const handleSectionItemResizeStart = (trackId, itemType, itemId, side, clickTime) => {
|
||||
const curTracks = activeTracks;
|
||||
const track = curTracks.find(t => t.id === trackId);
|
||||
if (!track) return;
|
||||
const items = itemType === 'section' ? track.sections : track.midiItems;
|
||||
const item = (items || []).find(it => it.id === itemId);
|
||||
if (!item) return;
|
||||
const start = itemType === 'section' ? item.start : item.startTime;
|
||||
setResizedSectionItem({ trackId, itemType, itemId, side, originalStart: start, originalDuration: item.duration });
|
||||
};
|
||||
|
||||
// ── Document-level mousemove/mouseup for Section/MIDI item drag ──
|
||||
useEffect(() => {
|
||||
const handleMouseMove = e => {
|
||||
const drag = draggedSectionItemRef.current;
|
||||
if (!drag) return;
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const time = mouseX / zoom;
|
||||
const newStart = Math.max(0, time - drag.clickOffset);
|
||||
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []);
|
||||
const updatedItems = items.filter(it => it.id !== drag.itemId);
|
||||
if (t.id === targetTrackId) {
|
||||
const movedItem = items.find(it => it.id === drag.itemId);
|
||||
if (movedItem) {
|
||||
updatedItems.push(drag.itemType === 'section'
|
||||
? { ...movedItem, start: newStart }
|
||||
: { ...movedItem, startTime: newStart });
|
||||
} else {
|
||||
if (drag.itemType === 'section') {
|
||||
updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4, color: '#06b6d4' });
|
||||
} else {
|
||||
updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4, notes: [], color: '#a78bfa' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return drag.itemType === 'section'
|
||||
? { ...t, sections: updatedItems }
|
||||
: { ...t, midiItems: updatedItems };
|
||||
}));
|
||||
if (drag.trackId !== targetTrackId) {
|
||||
setDraggedSectionItem(prev => ({ ...prev, trackId: targetTrackId }));
|
||||
}
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
const drag = draggedSectionItemRef.current;
|
||||
if (!drag) return;
|
||||
setDraggedSectionItem(null);
|
||||
showToast(`Đã di chuyển ${drag.itemType === 'section' ? 'section' : 'MIDI item'}.`, 'success');
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoom, activeTab, sessionTabs]);
|
||||
|
||||
// ── Document-level mousemove/mouseup for Section/MIDI item resize ──
|
||||
useEffect(() => {
|
||||
const handleMouseMove = e => {
|
||||
const resize = resizedSectionItemRef.current;
|
||||
if (!resize) return;
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const time = mouseX / zoom;
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
if (t.id !== resize.trackId) return t;
|
||||
const items = resize.itemType === 'section' ? [...(t.sections || [])] : [...(t.midiItems || [])];
|
||||
const idx = items.findIndex(it => it.id === resize.itemId);
|
||||
if (idx === -1) return t;
|
||||
const item = items[idx];
|
||||
if (resize.side === 'left') {
|
||||
const newStart = Math.min(time, resize.originalStart + resize.originalDuration - 0.1);
|
||||
const end = resize.originalStart + resize.originalDuration;
|
||||
const newDuration = end - newStart;
|
||||
if (newDuration < 0.1) return t;
|
||||
items[idx] = resize.itemType === 'section'
|
||||
? { ...item, start: newStart, duration: newDuration }
|
||||
: { ...item, startTime: newStart, duration: newDuration };
|
||||
} else {
|
||||
const newDuration = Math.max(0.1, time - resize.originalStart);
|
||||
items[idx] = { ...item, duration: newDuration };
|
||||
}
|
||||
return resize.itemType === 'section'
|
||||
? { ...t, sections: items }
|
||||
: { ...t, midiItems: items };
|
||||
}));
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
const resize = resizedSectionItemRef.current;
|
||||
if (!resize) return;
|
||||
setResizedSectionItem(null);
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoom, activeTab, sessionTabs]);
|
||||
|
||||
const handleSelectRange = (start, end, reset) => {
|
||||
const maxLen = maxDuration;
|
||||
const cleanStart = Math.max(0, Math.min(maxLen, start));
|
||||
@@ -6678,10 +7018,11 @@ const App = () => {
|
||||
|
||||
// ── Add Track ──
|
||||
const addNewTrack = () => {
|
||||
const newId = (tracks.length + 1).toString();
|
||||
const curTracks = activeTracks;
|
||||
const newId = (curTracks.length + 1).toString();
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const selectColor = colors[tracks.length % colors.length];
|
||||
setTracks(prev => [...prev, {
|
||||
const selectColor = colors[curTracks.length % colors.length];
|
||||
updateActiveTracks(prev => [...prev, {
|
||||
id: newId,
|
||||
name: `Track ${newId}`,
|
||||
buffer: null,
|
||||
@@ -6693,13 +7034,134 @@ const App = () => {
|
||||
solo: false,
|
||||
color: selectColor,
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}]);
|
||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||
setTimeout(() => lucide.createIcons(), 200);
|
||||
return newId;
|
||||
};
|
||||
|
||||
// ── Update tracks in active context (main session or section tab) ──
|
||||
const updateActiveTracks = updater => {
|
||||
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
||||
if (sessionTab) {
|
||||
setSessionTabs(prev => prev.map(st => st.id === activeTab ? { ...st, tracks: updater(st.tracks) } : st));
|
||||
} else {
|
||||
setTracks(updater);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Insert Track Below Selected ──
|
||||
const insertTrackBelow = () => {
|
||||
const curTracks = activeTracks;
|
||||
const newId = `t${Date.now()}`;
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const selectColor = colors[(curTracks.length) % colors.length];
|
||||
const newTrack = {
|
||||
id: newId,
|
||||
name: `Track ${newId}`,
|
||||
buffer: null,
|
||||
startTime: 0,
|
||||
height: 128,
|
||||
volumeDb: 0,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
solo: false,
|
||||
color: selectColor,
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
};
|
||||
updateActiveTracks(prev => {
|
||||
const idx = prev.findIndex(t => t.id === selectedTrackId);
|
||||
if (idx === -1) return [...prev, newTrack];
|
||||
const copy = [...prev];
|
||||
copy.splice(idx + 1, 0, newTrack);
|
||||
return copy;
|
||||
});
|
||||
setSelectedTrackId(newId);
|
||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||
setTimeout(() => lucide.createIcons(), 200);
|
||||
};
|
||||
|
||||
// ── Insert Section at Playhead ──
|
||||
const insertSectionAtPlayhead = () => {
|
||||
const track = tracks.find(t => t.id === selectedTrackId);
|
||||
if (!track) { showToast('Chọn track trước', 'warning'); return; }
|
||||
const section = {
|
||||
id: `sec_${Date.now()}`,
|
||||
name: 'Section',
|
||||
start: currentTime,
|
||||
duration: 4,
|
||||
color: track.color || '#06b6d4'
|
||||
};
|
||||
setTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
||||
...t,
|
||||
sections: [...(t.sections || []), section]
|
||||
} : t));
|
||||
showToast(`Đã thêm Section tại ${currentTime.toFixed(2)}s`, 'success');
|
||||
};
|
||||
|
||||
// ── Insert MIDI Item at Playhead ──
|
||||
const insertMidiItemAtPlayhead = () => {
|
||||
const curTracks = activeTracks;
|
||||
const track = curTracks.find(t => t.id === selectedTrackId);
|
||||
if (!track) { showToast('Chọn track trước', 'warning'); return; }
|
||||
const midiItem = {
|
||||
id: `midi_${Date.now()}`,
|
||||
name: 'MIDI Item',
|
||||
startTime: currentTime,
|
||||
duration: 4,
|
||||
notes: [],
|
||||
color: '#a78bfa'
|
||||
};
|
||||
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
||||
...t,
|
||||
midiItems: [...(t.midiItems || []), midiItem]
|
||||
} : t));
|
||||
showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`, 'success');
|
||||
};
|
||||
|
||||
// ── Insert Sound Clip at Cursor ──
|
||||
const insertSoundClipAtCursor = () => {
|
||||
const curTracks = activeTracks;
|
||||
const track = curTracks.find(t => t.id === selectedTrackId);
|
||||
if (!track) { showToast('Chọn track trước', 'warning'); return; }
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'audio/*';
|
||||
input.multiple = true;
|
||||
input.onchange = async e => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
let offset = currentTime;
|
||||
let count = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
uploadToServer(file, track.id);
|
||||
const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file);
|
||||
const clipId = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const newClip = { id: clipId, buffer: decodedBuffer, startTime: offset, name: file.name, speed: 1.0 };
|
||||
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
||||
...t,
|
||||
clips: [...(t.clips || []), newClip]
|
||||
} : t));
|
||||
offset += decodedBuffer.duration;
|
||||
count++;
|
||||
} catch (err) {
|
||||
showToast(`Lỗi nạp ${file.name}`, 'error');
|
||||
}
|
||||
}
|
||||
if (count > 0) showToast(`Đã chèn ${count} file âm thanh`, 'success');
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
// ── Server-side Export ──
|
||||
const triggerWavExport = async () => {
|
||||
let exportTracks;
|
||||
@@ -8570,7 +9032,10 @@ const App = () => {
|
||||
solo: false,
|
||||
color: '#0f766e',
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}, {
|
||||
id: '2',
|
||||
name: 'Track 02',
|
||||
@@ -8583,7 +9048,10 @@ const App = () => {
|
||||
solo: false,
|
||||
color: '#1d4ed8',
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}]);
|
||||
setSelectedTrackId('1');
|
||||
setProjectName('');
|
||||
@@ -8728,6 +9196,25 @@ const App = () => {
|
||||
handleDeleteTrack();
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
label: 'Insert',
|
||||
items: [...(!sessionTabs.some(s => s.id === activeTab) ? [{
|
||||
label: 'Insert Section',
|
||||
icon: 'folder-plus',
|
||||
action: insertSectionAtPlayhead
|
||||
}] : []), {
|
||||
label: 'Insert MIDI item',
|
||||
icon: 'music',
|
||||
action: insertMidiItemAtPlayhead
|
||||
}, {
|
||||
label: 'Insert sound clip',
|
||||
icon: 'file-input',
|
||||
action: insertSoundClipAtCursor
|
||||
}, {
|
||||
label: 'Insert track',
|
||||
icon: 'plus',
|
||||
action: insertTrackBelow
|
||||
}]
|
||||
}, {
|
||||
label: 'View',
|
||||
items: [{
|
||||
@@ -8780,7 +9267,7 @@ const App = () => {
|
||||
onClick: () => setMenuOpen(menuOpen === menu.label ? null : menu.label),
|
||||
className: `px-3 py-1 text-xs font-medium transition rounded ${menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`
|
||||
}, menu.label), menuOpen === menu.label && /*#__PURE__*/React.createElement("div", {
|
||||
className: "absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 z-50",
|
||||
className: `absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 z-50 ${menu.label === 'Edit' ? 'w-72' : 'w-64'}`,
|
||||
onClick: () => setMenuOpen(null)
|
||||
}, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", {
|
||||
key: i,
|
||||
@@ -8829,7 +9316,29 @@ const App = () => {
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "layout-dashboard",
|
||||
className: "w-3 h-3"
|
||||
})), " Main Session"), subTabs.map(st => /*#__PURE__*/React.createElement("div", {
|
||||
})), " Main Session"), sessionTabs.map(st => /*#__PURE__*/React.createElement("div", {
|
||||
key: st.id,
|
||||
className: "flex items-stretch"
|
||||
}, /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => setActiveTab(st.id),
|
||||
className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "layout-dashboard",
|
||||
className: "w-3 h-3"
|
||||
})), /*#__PURE__*/React.createElement("span", {
|
||||
className: "max-w-[120px] truncate"
|
||||
}, st.name)), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => closeSessionTab(st.id),
|
||||
className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs",
|
||||
title: "Close tab"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "x",
|
||||
className: "w-3 h-3"
|
||||
}))))), subTabs.map(st => /*#__PURE__*/React.createElement("div", {
|
||||
key: st.id,
|
||||
className: "flex items-stretch"
|
||||
}, /*#__PURE__*/React.createElement("button", {
|
||||
@@ -9818,24 +10327,24 @@ const App = () => {
|
||||
className: "flex-1 flex flex-col overflow-hidden min-w-0"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex-1 flex overflow-hidden"
|
||||
}, activeTab === 'main' ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||
ref: tcpContainerRef,
|
||||
onScroll: handleTCPScroll,
|
||||
className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900",
|
||||
style: {
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "sliders",
|
||||
className: "w-3.5 h-3.5 text-cyan-400"
|
||||
})), "TRACKS (", tracks.length, ")"), /*#__PURE__*/React.createElement("button", {
|
||||
}, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||
ref: tcpContainerRef,
|
||||
onScroll: handleTCPScroll,
|
||||
className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900",
|
||||
style: {
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "sliders",
|
||||
className: "w-3.5 h-3.5 text-cyan-400"
|
||||
})), "TRACKS (", activeTracks.length, ")"), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: addNewTrack,
|
||||
className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
@@ -9867,7 +10376,7 @@ const App = () => {
|
||||
className: "text-xs text-zinc-500"
|
||||
}, "BPM")))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"
|
||||
}, tracks.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
||||
}, activeTracks.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
||||
className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
@@ -9882,9 +10391,9 @@ const App = () => {
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "plus",
|
||||
"data-lucide": "plus-circle",
|
||||
className: "w-3.5 h-3.5"
|
||||
})), " Thêm Track Mới")) : tracks.map((track, idx) => {
|
||||
})), " Thêm Track Mới")) : activeTracks.map((track, idx) => {
|
||||
const isSelected = selectedTrackId === track.id;
|
||||
return /*#__PURE__*/React.createElement("div", {
|
||||
key: track.id,
|
||||
@@ -10152,7 +10661,7 @@ const App = () => {
|
||||
scrollLeft: scrollLeft
|
||||
}))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full"
|
||||
}, tracks.map((track, idx) => {
|
||||
}, activeTracks.map((track, idx) => {
|
||||
const isSelected = selectedTrackId === track.id;
|
||||
return /*#__PURE__*/React.createElement("div", {
|
||||
key: track.id,
|
||||
@@ -10180,12 +10689,15 @@ const App = () => {
|
||||
onContextMenu: handleContextMenu,
|
||||
onClipDragStart: handleClipDragStart,
|
||||
onClipStretchStart: handleClipStretchStart,
|
||||
onSectionItemDragStart: handleSectionItemDragStart,
|
||||
onSectionItemResizeStart: handleSectionItemResizeStart,
|
||||
onSelectionEdgeDragStart: handleSelectionEdgeDragStart,
|
||||
setSelectedClipId: setSelectedClipId,
|
||||
selectedClipId: selectedClipId,
|
||||
activeTool: activeTool,
|
||||
onSplitTrackAtTime: handleSplitTrackAtTime,
|
||||
onEditClipInSubTab: handleEditClipInSubTab,
|
||||
onEditSectionInTab: handleEditSectionInTab,
|
||||
snapValue: snapValue,
|
||||
bpm: bpm,
|
||||
selectionMode: selectionMode,
|
||||
@@ -10234,7 +10746,7 @@ const App = () => {
|
||||
}), /*#__PURE__*/React.createElement("div", {
|
||||
className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800",
|
||||
onMouseEnter: () => {
|
||||
if (draggedClipRef.current) {
|
||||
if (draggedClipRef.current || draggedSectionItemRef.current) {
|
||||
setHoveredTrackId(addNewTrack());
|
||||
}
|
||||
},
|
||||
@@ -10914,7 +11426,19 @@ const App = () => {
|
||||
top: contextMenu.y
|
||||
},
|
||||
onClick: e => e.stopPropagation()
|
||||
}, /*#__PURE__*/React.createElement("button", {
|
||||
}, contextMenu.sectionId ? /*#__PURE__*/React.createElement("button", {
|
||||
onClick: contextMenuEditSection,
|
||||
className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "file-edit",
|
||||
className: "w-3.5 h-3.5 text-amber-400 shrink-0"
|
||||
})), /*#__PURE__*/React.createElement("span", {
|
||||
className: "flex-1"
|
||||
}, "Edit Section"), /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
|
||||
}, "Ctrl+E")) : /*#__PURE__*/React.createElement("button", {
|
||||
onClick: contextMenuEdit,
|
||||
className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
|
||||
@@ -184,6 +184,9 @@ const WaveformLane = ({
|
||||
onContextMenu,
|
||||
onClipDragStart,
|
||||
onClipStretchStart,
|
||||
onSectionItemDragStart,
|
||||
onSectionItemResizeStart,
|
||||
onEditSectionInTab,
|
||||
onSelectionEdgeDragStart,
|
||||
setSelectedClipId,
|
||||
selectedClipId,
|
||||
@@ -440,6 +443,40 @@ const WaveformLane = ({
|
||||
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', drawWidth / 2, height / 2);
|
||||
}
|
||||
|
||||
// Draw sections
|
||||
const sections = track.sections || [];
|
||||
sections.forEach(sec => {
|
||||
const secStartLocal = sec.start * zoom - scrollLeft;
|
||||
const secWidth = sec.duration * zoom;
|
||||
if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return;
|
||||
ctx.fillStyle = sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)';
|
||||
ctx.fillRect(secStartLocal, 2, secWidth, height - 4);
|
||||
ctx.strokeStyle = sec.color || '#06b6d4';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(secStartLocal, 2, secWidth, height - 4);
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#e4e4e7';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.fillText(sec.name || 'Section', Math.max(secStartLocal + 4, 4), 14);
|
||||
});
|
||||
|
||||
// Draw MIDI items
|
||||
const midiItems = track.midiItems || [];
|
||||
midiItems.forEach(midi => {
|
||||
const midiStartLocal = midi.startTime * zoom - scrollLeft;
|
||||
const midiWidth = midi.duration * zoom;
|
||||
if (midiStartLocal + midiWidth < 0 || midiStartLocal > drawWidth) return;
|
||||
ctx.fillStyle = '#a78bfa33';
|
||||
ctx.fillRect(midiStartLocal, 2, midiWidth, height - 4);
|
||||
ctx.strokeStyle = '#a78bfa';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(midiStartLocal, 2, midiWidth, height - 4);
|
||||
ctx.fillStyle = '#c4b5fd';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14);
|
||||
});
|
||||
|
||||
// Selection highlight - local selection on this track
|
||||
if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
|
||||
const hlLeftLocal = localSelLeft * zoom - scrollLeft;
|
||||
@@ -521,6 +558,49 @@ const WaveformLane = ({
|
||||
canvasRef.current.style.cursor = 'ew-resize';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check section/MIDI item hover for resize or drag
|
||||
const allSections = track.sections || [];
|
||||
const allMidiItems = track.midiItems || [];
|
||||
const sectionTolerance = 8 / zoom;
|
||||
let foundSectionItem = null;
|
||||
let sectionItemEdge = null;
|
||||
const checkEdge = (item, startTime, dur) => {
|
||||
const leftEdge = Math.abs(time - startTime) <= sectionTolerance;
|
||||
const rightEdge = Math.abs(time - (startTime + dur)) <= sectionTolerance;
|
||||
if (leftEdge || rightEdge) return leftEdge ? 'left' : 'right';
|
||||
return null;
|
||||
};
|
||||
for (const sec of allSections) {
|
||||
const edge = checkEdge(sec, sec.start, sec.duration);
|
||||
if (edge) { foundSectionItem = { type: 'section', item: sec }; sectionItemEdge = edge; break; }
|
||||
}
|
||||
if (!foundSectionItem) {
|
||||
for (const midi of allMidiItems) {
|
||||
const edge = checkEdge(midi, midi.startTime, midi.duration);
|
||||
if (edge) { foundSectionItem = { type: 'midiItem', item: midi }; sectionItemEdge = edge; break; }
|
||||
}
|
||||
}
|
||||
if (foundSectionItem && sectionItemEdge) {
|
||||
canvasRef.current.style.cursor = 'ew-resize';
|
||||
return;
|
||||
}
|
||||
// Check body hover for drag
|
||||
if (!foundSectionItem) {
|
||||
for (const sec of allSections) {
|
||||
if (time >= sec.start && time < sec.start + sec.duration) { foundSectionItem = { type: 'section', item: sec }; break; }
|
||||
}
|
||||
}
|
||||
if (!foundSectionItem) {
|
||||
for (const midi of allMidiItems) {
|
||||
if (time >= midi.startTime && time < midi.startTime + midi.duration) { foundSectionItem = { type: 'midiItem', item: midi }; break; }
|
||||
}
|
||||
}
|
||||
if (foundSectionItem) {
|
||||
canvasRef.current.style.cursor = 'grab';
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
const isOverClip = !!hoveredClip;
|
||||
if (activeTool === 'pen') {
|
||||
@@ -613,6 +693,46 @@ const WaveformLane = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check section/MIDI item edge for resize, then body for drag
|
||||
const secItems = track.sections || [];
|
||||
const midiItemsArray = track.midiItems || [];
|
||||
const secTol = 8 / zoom;
|
||||
let hitItem = null;
|
||||
let hitEdge = null;
|
||||
for (const sec of secItems) {
|
||||
if (Math.abs(time - sec.start) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'left'; break; }
|
||||
if (Math.abs(time - (sec.start + sec.duration)) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'right'; break; }
|
||||
}
|
||||
if (!hitItem) {
|
||||
for (const midi of midiItemsArray) {
|
||||
if (Math.abs(time - midi.startTime) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'left'; break; }
|
||||
if (Math.abs(time - (midi.startTime + midi.duration)) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'right'; break; }
|
||||
}
|
||||
}
|
||||
if (hitItem && hitEdge) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onSectionItemResizeStart) onSectionItemResizeStart(track.id, hitItem.type, hitItem.id, hitEdge, time);
|
||||
return;
|
||||
}
|
||||
if (!hitItem) {
|
||||
for (const sec of secItems) {
|
||||
if (time >= sec.start && time < sec.start + sec.duration) { hitItem = { type: 'section', id: sec.id, start: sec.start }; break; }
|
||||
}
|
||||
}
|
||||
if (!hitItem) {
|
||||
for (const midi of midiItemsArray) {
|
||||
if (time >= midi.startTime && time < midi.startTime + midi.duration) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime }; break; }
|
||||
}
|
||||
}
|
||||
if (hitItem && !e.altKey && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start);
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
|
||||
// Set selected clip ID
|
||||
@@ -695,6 +815,18 @@ const WaveformLane = ({
|
||||
name: track.name,
|
||||
speed: track.speed || 1.0
|
||||
}] : [];
|
||||
// Check double-click on section first
|
||||
const dblSecItems = track.sections || [];
|
||||
let dblSecHit = null;
|
||||
for (const sec of dblSecItems) {
|
||||
if (time >= sec.start && time < sec.start + sec.duration) { dblSecHit = sec; break; }
|
||||
}
|
||||
if (dblSecHit) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onEditSectionInTab) onEditSectionInTab(track.id, dblSecHit.id);
|
||||
return;
|
||||
}
|
||||
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
if (clickedClip) {
|
||||
e.preventDefault();
|
||||
@@ -711,7 +843,12 @@ const WaveformLane = ({
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + (scrollLeft || 0);
|
||||
const time = Math.max(0, x / zoom);
|
||||
if (onContextMenu) onContextMenu(e, track.id, time);
|
||||
const secList = track.sections || [];
|
||||
let hitSectionId = null;
|
||||
for (const sec of secList) {
|
||||
if (time >= sec.start && time < sec.start + sec.duration) { hitSectionId = sec.id; break; }
|
||||
}
|
||||
if (onContextMenu) onContextMenu(e, track.id, time, hitSectionId);
|
||||
}
|
||||
}));
|
||||
};
|
||||
@@ -3444,7 +3581,10 @@ const App = () => {
|
||||
solo: false,
|
||||
color: '#1d4ed8',
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}]);
|
||||
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
|
||||
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
|
||||
@@ -3574,6 +3714,8 @@ const App = () => {
|
||||
const [menuOpen, setMenuOpen] = useState(null);
|
||||
const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId }
|
||||
const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap }
|
||||
const [draggedSectionItem, setDraggedSectionItem] = useState(null);
|
||||
const [resizedSectionItem, setResizedSectionItem] = useState(null);
|
||||
const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited
|
||||
const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId }
|
||||
const [editNameInput, setEditNameInput] = useState('');
|
||||
@@ -3654,6 +3796,11 @@ const App = () => {
|
||||
const [subTabGainVal, setSubTabGainVal] = useState(100);
|
||||
const [subTabPitchVal, setSubTabPitchVal] = useState(0);
|
||||
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
|
||||
const [sessionTabs, setSessionTabs] = useState([]); // [{id, name, tracks}, ...]
|
||||
const activeTracks = useMemo(() => {
|
||||
const st = sessionTabs.find(s => s.id === activeTab);
|
||||
return st ? st.tracks : tracks;
|
||||
}, [activeTab, sessionTabs, tracks]);
|
||||
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||
|
||||
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
|
||||
@@ -4333,7 +4480,10 @@ const App = () => {
|
||||
solo: false,
|
||||
color: '#0f766e',
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}, {
|
||||
id: '2',
|
||||
name: 'Track 02',
|
||||
@@ -4346,10 +4496,11 @@ const App = () => {
|
||||
solo: false,
|
||||
color: '#1d4ed8',
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}]);
|
||||
setSelectedTrackId('1');
|
||||
showToast('New project created', 'info');
|
||||
return;
|
||||
}
|
||||
if (ctrl && !alt && e.key === 's') {
|
||||
@@ -4625,6 +4776,27 @@ const App = () => {
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
// ── Double-click/Edit Section: open Main Session in new tab ──
|
||||
const handleEditSectionInTab = (trackId, sectionId) => {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (!track) return;
|
||||
const section = (track.sections || []).find(s => s.id === sectionId);
|
||||
if (!section) return;
|
||||
const existing = sessionTabs.find(s => s.sectionId === sectionId);
|
||||
if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; }
|
||||
const tabId = 'session_' + Date.now();
|
||||
const tabName = section.name || 'Section';
|
||||
const clonedTracks = tracks.map(t => ({
|
||||
...t,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: [],
|
||||
markers: []
|
||||
}));
|
||||
setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]);
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
|
||||
const applySubTab = tabId => {
|
||||
const subTab = subTabs.find(s => s.id === tabId);
|
||||
@@ -4960,6 +5132,10 @@ const App = () => {
|
||||
setSubTabs(prev => prev.filter(s => s.id !== tabId));
|
||||
if (activeTab === tabId) setActiveTab('main');
|
||||
};
|
||||
const closeSessionTab = tabId => {
|
||||
setSessionTabs(prev => prev.filter(s => s.id !== tabId));
|
||||
if (activeTab === tabId) setActiveTab('main');
|
||||
};
|
||||
const updateSubTabEffects = (tabId, effects) => {
|
||||
setSubTabs(prev => prev.map(s => s.id === tabId ? {
|
||||
...s,
|
||||
@@ -5005,18 +5181,26 @@ const App = () => {
|
||||
};
|
||||
|
||||
// ── Context Menu Handlers ──
|
||||
const handleContextMenu = (e, trackId, clickTime) => {
|
||||
const handleContextMenu = (e, trackId, clickTime, sectionId) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
trackId,
|
||||
time: clickTime || currentTime
|
||||
time: clickTime || currentTime,
|
||||
sectionId: sectionId || null
|
||||
});
|
||||
};
|
||||
const closeContextMenu = () => setContextMenu(null);
|
||||
|
||||
const contextMenuEditSection = () => {
|
||||
if (contextMenu.sectionId) {
|
||||
handleEditSectionInTab(contextMenu.trackId, contextMenu.sectionId);
|
||||
}
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
// Close context menu on any click outside
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
@@ -5051,15 +5235,27 @@ const App = () => {
|
||||
};
|
||||
const contextMenuDelete = () => {
|
||||
const tid = contextMenu.trackId;
|
||||
const beforeSnap = captureTrackSnapshot(tid);
|
||||
setTracks(prev => prev.filter(t => t.id !== tid));
|
||||
const afterSnap = captureTrackSnapshot(tid);
|
||||
pushAction('DELETE', tid, beforeSnap, afterSnap);
|
||||
if (selectedTrackId === tid) {
|
||||
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
||||
if (sessionTab) {
|
||||
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
|
||||
if (selectedTrackId === tid) setSelectedTrackId('1');
|
||||
closeContextMenu();
|
||||
showToast('Đã xoá track.', 'info');
|
||||
} else {
|
||||
const track = tracks.find(t => t.id === tid);
|
||||
if (track && track.sections && track.sections.length > 0) {
|
||||
closeContextMenu();
|
||||
showToast('Không thể xoá track chứa Section item.', 'warning');
|
||||
return;
|
||||
}
|
||||
const beforeSnap = captureTrackSnapshot(tid);
|
||||
setTracks(prev => prev.filter(t => t.id !== tid));
|
||||
const afterSnap = captureTrackSnapshot(tid);
|
||||
pushAction('DELETE', tid, beforeSnap, afterSnap);
|
||||
if (selectedTrackId === tid) setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
closeContextMenu();
|
||||
showToast('Đã xoá track.', 'info');
|
||||
}
|
||||
closeContextMenu();
|
||||
showToast('Đã xoá track.', 'info');
|
||||
};
|
||||
const contextMenuCopy = () => {
|
||||
const track = tracks.find(t => t.id === contextMenu.trackId);
|
||||
@@ -5441,8 +5637,20 @@ const App = () => {
|
||||
};
|
||||
const handleDeleteTrack = () => {
|
||||
const tid = selectedTrackId;
|
||||
setTracks(p => p.filter(t => t.id !== tid));
|
||||
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
||||
if (sessionTab) {
|
||||
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
|
||||
setSelectedTrackId(activeTracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
} else {
|
||||
const curTracks = tracks;
|
||||
const track = curTracks.find(t => t.id === tid);
|
||||
if (track && track.sections && track.sections.length > 0) {
|
||||
showToast('Không thể xoá track chứa Section item.', 'warning');
|
||||
return;
|
||||
}
|
||||
setTracks(p => p.filter(t => t.id !== tid));
|
||||
setSelectedTrackId(curTracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||
}
|
||||
showToast('Deleted track.', 'info');
|
||||
};
|
||||
|
||||
@@ -6172,6 +6380,10 @@ const App = () => {
|
||||
hoveredTrackIdRef.current = hoveredTrackId;
|
||||
const captureTrackSnapshotRef = useRef(null);
|
||||
captureTrackSnapshotRef.current = captureTrackSnapshot;
|
||||
const draggedSectionItemRef = useRef(null);
|
||||
draggedSectionItemRef.current = draggedSectionItem;
|
||||
const resizedSectionItemRef = useRef(null);
|
||||
resizedSectionItemRef.current = resizedSectionItem;
|
||||
let clipSeqCounter = 0;
|
||||
const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`;
|
||||
const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => {
|
||||
@@ -6315,14 +6527,26 @@ const App = () => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
const deleteTrack = trackId => {
|
||||
const beforeSnap = captureTrackSnapshot(trackId);
|
||||
setTracks(prev => {
|
||||
const filtered = prev.filter(t => t.id !== trackId);
|
||||
if (filtered.length > 0) {
|
||||
setSelectedTrackId(filtered[0].id);
|
||||
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
||||
if (sessionTab) {
|
||||
updateActiveTracks(prev => {
|
||||
const filtered = prev.filter(t => t.id !== trackId);
|
||||
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
|
||||
return filtered;
|
||||
});
|
||||
} else {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (track && track.sections && track.sections.length > 0) {
|
||||
showToast('Không thể xoá track chứa Section item.', 'warning');
|
||||
return;
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
const beforeSnap = captureTrackSnapshot(trackId);
|
||||
setTracks(prev => {
|
||||
const filtered = prev.filter(t => t.id !== trackId);
|
||||
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
showToast('Đã xóa track.', 'info');
|
||||
};
|
||||
useEffect(() => {
|
||||
@@ -6456,6 +6680,123 @@ const App = () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoom]);
|
||||
|
||||
// ── Section / MIDI Item Drag Start ──
|
||||
const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset) => {
|
||||
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset });
|
||||
};
|
||||
|
||||
// ── Section / MIDI Item Resize Start ──
|
||||
const handleSectionItemResizeStart = (trackId, itemType, itemId, side, clickTime) => {
|
||||
const curTracks = activeTracks;
|
||||
const track = curTracks.find(t => t.id === trackId);
|
||||
if (!track) return;
|
||||
const items = itemType === 'section' ? track.sections : track.midiItems;
|
||||
const item = (items || []).find(it => it.id === itemId);
|
||||
if (!item) return;
|
||||
const start = itemType === 'section' ? item.start : item.startTime;
|
||||
setResizedSectionItem({ trackId, itemType, itemId, side, originalStart: start, originalDuration: item.duration });
|
||||
};
|
||||
|
||||
// ── Document-level mousemove/mouseup for Section/MIDI item drag ──
|
||||
useEffect(() => {
|
||||
const handleMouseMove = e => {
|
||||
const drag = draggedSectionItemRef.current;
|
||||
if (!drag) return;
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const time = mouseX / zoom;
|
||||
const newStart = Math.max(0, time - drag.clickOffset);
|
||||
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []);
|
||||
const updatedItems = items.filter(it => it.id !== drag.itemId);
|
||||
if (t.id === targetTrackId) {
|
||||
const movedItem = items.find(it => it.id === drag.itemId);
|
||||
if (movedItem) {
|
||||
updatedItems.push(drag.itemType === 'section'
|
||||
? { ...movedItem, start: newStart }
|
||||
: { ...movedItem, startTime: newStart });
|
||||
} else {
|
||||
if (drag.itemType === 'section') {
|
||||
updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4, color: '#06b6d4' });
|
||||
} else {
|
||||
updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4, notes: [], color: '#a78bfa' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return drag.itemType === 'section'
|
||||
? { ...t, sections: updatedItems }
|
||||
: { ...t, midiItems: updatedItems };
|
||||
}));
|
||||
if (drag.trackId !== targetTrackId) {
|
||||
setDraggedSectionItem(prev => ({ ...prev, trackId: targetTrackId }));
|
||||
}
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
const drag = draggedSectionItemRef.current;
|
||||
if (!drag) return;
|
||||
setDraggedSectionItem(null);
|
||||
showToast(`Đã di chuyển ${drag.itemType === 'section' ? 'section' : 'MIDI item'}.`, 'success');
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoom, activeTab, sessionTabs]);
|
||||
|
||||
// ── Document-level mousemove/mouseup for Section/MIDI item resize ──
|
||||
useEffect(() => {
|
||||
const handleMouseMove = e => {
|
||||
const resize = resizedSectionItemRef.current;
|
||||
if (!resize) return;
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const time = mouseX / zoom;
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
if (t.id !== resize.trackId) return t;
|
||||
const items = resize.itemType === 'section' ? [...(t.sections || [])] : [...(t.midiItems || [])];
|
||||
const idx = items.findIndex(it => it.id === resize.itemId);
|
||||
if (idx === -1) return t;
|
||||
const item = items[idx];
|
||||
if (resize.side === 'left') {
|
||||
const newStart = Math.min(time, resize.originalStart + resize.originalDuration - 0.1);
|
||||
const end = resize.originalStart + resize.originalDuration;
|
||||
const newDuration = end - newStart;
|
||||
if (newDuration < 0.1) return t;
|
||||
items[idx] = resize.itemType === 'section'
|
||||
? { ...item, start: newStart, duration: newDuration }
|
||||
: { ...item, startTime: newStart, duration: newDuration };
|
||||
} else {
|
||||
const newDuration = Math.max(0.1, time - resize.originalStart);
|
||||
items[idx] = { ...item, duration: newDuration };
|
||||
}
|
||||
return resize.itemType === 'section'
|
||||
? { ...t, sections: items }
|
||||
: { ...t, midiItems: items };
|
||||
}));
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
const resize = resizedSectionItemRef.current;
|
||||
if (!resize) return;
|
||||
setResizedSectionItem(null);
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoom, activeTab, sessionTabs]);
|
||||
|
||||
const handleSelectRange = (start, end, reset) => {
|
||||
const maxLen = maxDuration;
|
||||
const cleanStart = Math.max(0, Math.min(maxLen, start));
|
||||
@@ -6769,12 +7110,23 @@ const App = () => {
|
||||
showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success');
|
||||
};
|
||||
|
||||
// ── Update tracks in active context (main session or section tab) ──
|
||||
const updateActiveTracks = updater => {
|
||||
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
||||
if (sessionTab) {
|
||||
setSessionTabs(prev => prev.map(st => st.id === activeTab ? { ...st, tracks: updater(st.tracks) } : st));
|
||||
} else {
|
||||
setTracks(updater);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Add Track ──
|
||||
const addNewTrack = () => {
|
||||
const newId = (tracks.length + 1).toString();
|
||||
const curTracks = activeTracks;
|
||||
const newId = (curTracks.length + 1).toString();
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const selectColor = colors[tracks.length % colors.length];
|
||||
setTracks(prev => [...prev, {
|
||||
const selectColor = colors[curTracks.length % colors.length];
|
||||
updateActiveTracks(prev => [...prev, {
|
||||
id: newId,
|
||||
name: `Track ${newId}`,
|
||||
buffer: null,
|
||||
@@ -6786,13 +7138,124 @@ const App = () => {
|
||||
solo: false,
|
||||
color: selectColor,
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}]);
|
||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||
setTimeout(() => lucide.createIcons(), 200);
|
||||
return newId;
|
||||
};
|
||||
|
||||
// ── Insert Track Below Selected ──
|
||||
const insertTrackBelow = () => {
|
||||
const curTracks = activeTracks;
|
||||
const newId = `t${Date.now()}`;
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const selectColor = colors[curTracks.length % colors.length];
|
||||
const newTrack = {
|
||||
id: newId,
|
||||
name: `Track ${newId}`,
|
||||
buffer: null,
|
||||
startTime: 0,
|
||||
height: 128,
|
||||
volumeDb: 0,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
solo: false,
|
||||
color: selectColor,
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
};
|
||||
updateActiveTracks(prev => {
|
||||
const idx = prev.findIndex(t => t.id === selectedTrackId);
|
||||
if (idx === -1) return [...prev, newTrack];
|
||||
const copy = [...prev];
|
||||
copy.splice(idx + 1, 0, newTrack);
|
||||
return copy;
|
||||
});
|
||||
setSelectedTrackId(newId);
|
||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||
setTimeout(() => lucide.createIcons(), 200);
|
||||
};
|
||||
|
||||
// ── Insert Section at Playhead ──
|
||||
const insertSectionAtPlayhead = () => {
|
||||
const track = tracks.find(t => t.id === selectedTrackId);
|
||||
if (!track) { showToast('Chọn track trước', 'warning'); return; }
|
||||
const section = {
|
||||
id: `sec_${Date.now()}`,
|
||||
name: 'Section',
|
||||
start: currentTime,
|
||||
duration: 4,
|
||||
color: track.color || '#06b6d4'
|
||||
};
|
||||
setTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
||||
...t,
|
||||
sections: [...(t.sections || []), section]
|
||||
} : t));
|
||||
showToast(`Đã thêm Section tại ${currentTime.toFixed(2)}s`, 'success');
|
||||
};
|
||||
|
||||
// ── Insert MIDI Item at Playhead ──
|
||||
const insertMidiItemAtPlayhead = () => {
|
||||
const curTracks = activeTracks;
|
||||
const track = curTracks.find(t => t.id === selectedTrackId);
|
||||
if (!track) { showToast('Chọn track trước', 'warning'); return; }
|
||||
const midiItem = {
|
||||
id: `midi_${Date.now()}`,
|
||||
name: 'MIDI Item',
|
||||
startTime: currentTime,
|
||||
duration: 4,
|
||||
notes: [],
|
||||
color: '#a78bfa'
|
||||
};
|
||||
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
||||
...t,
|
||||
midiItems: [...(t.midiItems || []), midiItem]
|
||||
} : t));
|
||||
showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`, 'success');
|
||||
};
|
||||
|
||||
// ── Insert Sound Clip at Cursor ──
|
||||
const insertSoundClipAtCursor = () => {
|
||||
const curTracks = activeTracks;
|
||||
const track = curTracks.find(t => t.id === selectedTrackId);
|
||||
if (!track) { showToast('Chọn track trước', 'warning'); return; }
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'audio/*';
|
||||
input.multiple = true;
|
||||
input.onchange = async e => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
let offset = currentTime;
|
||||
let count = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
uploadToServer(file, track.id);
|
||||
const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file);
|
||||
const clipId = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const newClip = { id: clipId, buffer: decodedBuffer, startTime: offset, name: file.name, speed: 1.0 };
|
||||
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
||||
...t,
|
||||
clips: [...(t.clips || []), newClip]
|
||||
} : t));
|
||||
offset += decodedBuffer.duration;
|
||||
count++;
|
||||
} catch (err) {
|
||||
showToast(`Lỗi nạp ${file.name}`, 'error');
|
||||
}
|
||||
}
|
||||
if (count > 0) showToast(`Đã chèn ${count} file âm thanh`, 'success');
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
// ── Server-side Export ──
|
||||
const triggerWavExport = async () => {
|
||||
let exportTracks;
|
||||
@@ -9061,7 +9524,10 @@ const App = () => {
|
||||
solo: false,
|
||||
color: '#0f766e',
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}, {
|
||||
id: '2',
|
||||
name: 'Track 02',
|
||||
@@ -9074,7 +9540,10 @@ const App = () => {
|
||||
solo: false,
|
||||
color: '#1d4ed8',
|
||||
markers: [],
|
||||
serverFileId: null
|
||||
serverFileId: null,
|
||||
clips: [],
|
||||
sections: [],
|
||||
midiItems: []
|
||||
}]);
|
||||
setSelectedTrackId('1');
|
||||
setProjectName('');
|
||||
@@ -9219,6 +9688,25 @@ const App = () => {
|
||||
handleDeleteTrack();
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
label: 'Insert',
|
||||
items: [...(!sessionTabs.some(s => s.id === activeTab) ? [{
|
||||
label: 'Insert Section',
|
||||
icon: 'folder-plus',
|
||||
action: insertSectionAtPlayhead
|
||||
}] : []), {
|
||||
label: 'Insert MIDI item',
|
||||
icon: 'music',
|
||||
action: insertMidiItemAtPlayhead
|
||||
}, {
|
||||
label: 'Insert sound clip',
|
||||
icon: 'file-input',
|
||||
action: insertSoundClipAtCursor
|
||||
}, {
|
||||
label: 'Insert track',
|
||||
icon: 'plus',
|
||||
action: insertTrackBelow
|
||||
}]
|
||||
}, {
|
||||
label: 'View',
|
||||
items: [{
|
||||
@@ -9271,7 +9759,7 @@ const App = () => {
|
||||
onClick: () => setMenuOpen(menuOpen === menu.label ? null : menu.label),
|
||||
className: `px-3 py-1 text-xs font-medium transition rounded ${menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`
|
||||
}, menu.label), menuOpen === menu.label && /*#__PURE__*/React.createElement("div", {
|
||||
className: "absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 z-50",
|
||||
className: `absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 z-50 ${menu.label === 'Edit' ? 'w-72' : 'w-64'}`,
|
||||
onClick: () => setMenuOpen(null)
|
||||
}, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", {
|
||||
key: i,
|
||||
@@ -9320,7 +9808,29 @@ const App = () => {
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "layout-dashboard",
|
||||
className: "w-3 h-3"
|
||||
})), " Main Session"), subTabs.map(st => /*#__PURE__*/React.createElement("div", {
|
||||
})), " Main Session"), sessionTabs.map(st => /*#__PURE__*/React.createElement("div", {
|
||||
key: st.id,
|
||||
className: "flex items-stretch"
|
||||
}, /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => setActiveTab(st.id),
|
||||
className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "layout-dashboard",
|
||||
className: "w-3 h-3"
|
||||
})), /*#__PURE__*/React.createElement("span", {
|
||||
className: "max-w-[120px] truncate"
|
||||
}, st.name)), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => closeSessionTab(st.id),
|
||||
className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs",
|
||||
title: "Close tab"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "x",
|
||||
className: "w-3 h-3"
|
||||
}))))), subTabs.map(st => /*#__PURE__*/React.createElement("div", {
|
||||
key: st.id,
|
||||
className: "flex items-stretch"
|
||||
}, /*#__PURE__*/React.createElement("button", {
|
||||
@@ -10367,7 +10877,7 @@ const App = () => {
|
||||
className: "flex-1 flex flex-col overflow-hidden min-w-0"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex-1 flex overflow-hidden"
|
||||
}, activeTab === 'main' ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||
}, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||
ref: tcpContainerRef,
|
||||
onScroll: handleTCPScroll,
|
||||
className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900",
|
||||
@@ -10384,7 +10894,7 @@ const App = () => {
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "sliders",
|
||||
className: "w-3.5 h-3.5 text-cyan-400"
|
||||
})), "TRACKS (", tracks.length, ")"), /*#__PURE__*/React.createElement("button", {
|
||||
})), "TRACKS (", activeTracks.length, ")"), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: addNewTrack,
|
||||
className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
@@ -10416,7 +10926,7 @@ const App = () => {
|
||||
className: "text-xs text-zinc-500"
|
||||
}, "BPM")))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"
|
||||
}, tracks.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
||||
}, activeTracks.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
||||
className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
@@ -10433,7 +10943,7 @@ const App = () => {
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "plus",
|
||||
className: "w-3.5 h-3.5"
|
||||
})), " Thêm Track Mới")) : tracks.map((track, idx) => {
|
||||
})), " Thêm Track Mới")) : activeTracks.map((track, idx) => {
|
||||
const isSelected = selectedTrackId === track.id;
|
||||
return /*#__PURE__*/React.createElement("div", {
|
||||
key: track.id,
|
||||
@@ -10711,7 +11221,7 @@ const App = () => {
|
||||
scrollLeft: scrollLeft
|
||||
}))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full"
|
||||
}, tracks.map((track, idx) => {
|
||||
}, activeTracks.map((track, idx) => {
|
||||
const isSelected = selectedTrackId === track.id;
|
||||
return /*#__PURE__*/React.createElement("div", {
|
||||
key: track.id,
|
||||
@@ -10739,12 +11249,15 @@ const App = () => {
|
||||
onContextMenu: handleContextMenu,
|
||||
onClipDragStart: handleClipDragStart,
|
||||
onClipStretchStart: handleClipStretchStart,
|
||||
onSectionItemDragStart: handleSectionItemDragStart,
|
||||
onSectionItemResizeStart: handleSectionItemResizeStart,
|
||||
onSelectionEdgeDragStart: handleSelectionEdgeDragStart,
|
||||
setSelectedClipId: setSelectedClipId,
|
||||
selectedClipId: selectedClipId,
|
||||
activeTool: activeTool,
|
||||
onSplitTrackAtTime: handleSplitTrackAtTime,
|
||||
onEditClipInSubTab: handleEditClipInSubTab,
|
||||
onEditSectionInTab: handleEditSectionInTab,
|
||||
snapValue: snapValue,
|
||||
bpm: bpm,
|
||||
selectionMode: selectionMode,
|
||||
@@ -10793,7 +11306,7 @@ const App = () => {
|
||||
}), /*#__PURE__*/React.createElement("div", {
|
||||
className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800",
|
||||
onMouseEnter: () => {
|
||||
if (draggedClipRef.current) {
|
||||
if (draggedClipRef.current || draggedSectionItemRef.current) {
|
||||
setHoveredTrackId(addNewTrack());
|
||||
}
|
||||
},
|
||||
@@ -11473,7 +11986,19 @@ const App = () => {
|
||||
top: contextMenu.y
|
||||
},
|
||||
onClick: e => e.stopPropagation()
|
||||
}, /*#__PURE__*/React.createElement("button", {
|
||||
}, contextMenu.sectionId ? /*#__PURE__*/React.createElement("button", {
|
||||
onClick: contextMenuEditSection,
|
||||
className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "file-edit",
|
||||
className: "w-3.5 h-3.5 text-amber-400 shrink-0"
|
||||
})), /*#__PURE__*/React.createElement("span", {
|
||||
className: "flex-1"
|
||||
}, "Edit Section"), /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
|
||||
}, "Ctrl+E")) : /*#__PURE__*/React.createElement("button", {
|
||||
onClick: contextMenuEdit,
|
||||
className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user