feat: bổ sung phần menu insert items

This commit is contained in:
2026-07-22 20:54:12 +07:00
parent 9843a33a9b
commit 5a80657b2b
3 changed files with 420 additions and 2 deletions
+210 -1
View File
@@ -184,6 +184,8 @@ const WaveformLane = ({
onContextMenu,
onClipDragStart,
onClipStretchStart,
onSectionItemDragStart,
onSectionItemResizeStart,
onSelectionEdgeDragStart,
setSelectedClipId,
selectedClipId,
@@ -555,6 +557,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') {
@@ -647,6 +692,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
@@ -3560,6 +3645,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('');
@@ -6146,6 +6233,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}`;
@@ -6408,6 +6499,122 @@ 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 track = tracks.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;
setTracks(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]);
// 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;
setTracks(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]);
const handleSelectRange = (start, end, reset) => {
const maxLen = maxDuration;
const cleanStart = Math.max(0, Math.min(maxLen, start));
@@ -10352,6 +10559,8 @@ const App = () => {
onContextMenu: handleContextMenu,
onClipDragStart: handleClipDragStart,
onClipStretchStart: handleClipStretchStart,
onSectionItemDragStart: handleSectionItemDragStart,
onSectionItemResizeStart: handleSectionItemResizeStart,
onSelectionEdgeDragStart: handleSelectionEdgeDragStart,
setSelectedClipId: setSelectedClipId,
selectedClipId: selectedClipId,
@@ -10406,7 +10615,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());
}
},
+210 -1
View File
@@ -184,6 +184,8 @@ const WaveformLane = ({
onContextMenu,
onClipDragStart,
onClipStretchStart,
onSectionItemDragStart,
onSectionItemResizeStart,
onSelectionEdgeDragStart,
setSelectedClipId,
selectedClipId,
@@ -555,6 +557,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') {
@@ -647,6 +692,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
@@ -3611,6 +3696,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('');
@@ -6213,6 +6300,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) => {
@@ -6497,6 +6588,122 @@ 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 track = tracks.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;
setTracks(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]);
// ── 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;
setTracks(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]);
const handleSelectRange = (start, end, reset) => {
const maxLen = maxDuration;
const cleanStart = Math.max(0, Math.min(maxLen, start));
@@ -10913,6 +11120,8 @@ const App = () => {
onContextMenu: handleContextMenu,
onClipDragStart: handleClipDragStart,
onClipStretchStart: handleClipStretchStart,
onSectionItemDragStart: handleSectionItemDragStart,
onSectionItemResizeStart: handleSectionItemResizeStart,
onSelectionEdgeDragStart: handleSelectionEdgeDragStart,
setSelectedClipId: setSelectedClipId,
selectedClipId: selectedClipId,
@@ -10967,7 +11176,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());
}
},
Binary file not shown.