feat: Ctrl+drag sweep select + multi-item copy/drag

- Ctrl+drag on empty space: sweep range selection overlay
- Mouseup selects all items (MIDI, sections, clips) in sweep
- Selected items rendered with amber border highlight
- Ctrl+drag on section/MIDI item: duplicates the item
- Drag selected items: moves entire selected group together
- Ctrl+drag selected items: duplicates the entire group
This commit is contained in:
2026-07-27 21:46:20 +07:00
parent 80a5352645
commit fd24c2dcd8
3 changed files with 220 additions and 36 deletions
+198 -22
View File
@@ -410,6 +410,8 @@ const WaveformLane = ({
activeTool,
onSplitTrackAtTime,
onEditClipInSubTab,
selectedItemIds,
onSweepSelectStart,
snapValue,
bpm,
scrollLeft,
@@ -701,11 +703,12 @@ const WaveformLane = ({
const secStartLocal = sec.start * zoom - scrollLeftVal; // use secStartLocal NOT secStart
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)';
var isSecSelected = selectedItemIds && selectedItemIds.has(sec.id);
ctx.fillStyle = isSecSelected ? 'rgba(245, 158, 11, 0.35)' : (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.strokeStyle = isSecSelected ? '#f59e0b' : (sec.color || '#06b6d4');
ctx.lineWidth = isSecSelected ? 2.5 : 1;
ctx.setLineDash(isSecSelected ? [] : [4, 4]);
ctx.strokeRect(secStartLocal, 2, secWidth, height - 4);
ctx.setLineDash([]);
ctx.fillStyle = '#e4e4e7';
@@ -792,10 +795,11 @@ const WaveformLane = ({
console.log(`[DevLog] [Canvas Draw] Rendering MIDI item: ID=${midi.id}, Name="${midi.name}", Start=${midiStartLocal.toFixed(1)}px, Width=${midiWidth.toFixed(1)}px, NotesCount=${midi.notes?.length || 0}`);
}
ctx.fillStyle = isRecordingItem ? 'rgba(239, 68, 68, 0.2)' : '#a78bfa33';
var isMidiSelected = selectedItemIds && selectedItemIds.has(midi.id);
ctx.fillStyle = isMidiSelected ? 'rgba(245, 158, 11, 0.35)' : (isRecordingItem ? 'rgba(239, 68, 68, 0.2)' : '#a78bfa33');
ctx.fillRect(midiStartLocal, 2, midiWidth, height - 4);
ctx.strokeStyle = isRecordingItem ? '#ef4444' : '#a78bfa';
ctx.lineWidth = 1.5;
ctx.strokeStyle = isMidiSelected ? '#f59e0b' : (isRecordingItem ? '#ef4444' : '#a78bfa');
ctx.lineWidth = isMidiSelected ? 2.5 : 1.5;
ctx.strokeRect(midiStartLocal, 2, midiWidth, height - 4);
ctx.fillStyle = isRecordingItem ? '#fca5a5' : '#c4b5fd';
ctx.font = 'bold 9px sans-serif';
@@ -833,7 +837,7 @@ const WaveformLane = ({
ctx.lineWidth = 1;
ctx.strokeRect(hlLeftLocal, 0, hlWidth, height);
}
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft, recordingState, recTempMidiNotes, recStartTimelineTime, canvasRedrawCount, currentTime]);
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft, recordingState, recTempMidiNotes, recStartTimelineTime, canvasRedrawCount, currentTime, selectedItemIds]);
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
key: "virtual-spacer",
style: {
@@ -1072,10 +1076,10 @@ const WaveformLane = ({
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) {
if (hitItem && !e.altKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start);
if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.ctrlKey);
return;
}
@@ -1136,12 +1140,11 @@ const WaveformLane = ({
return;
}
// Check if Ctrl+Click outside active local selection to clear
if (e.ctrlKey) {
if (onClearLocalSelection) onClearLocalSelection();
if (onSetSelectionMode) onSetSelectionMode(null);
if (onSetSelectionStart) onSetSelectionStart(null);
if (onSetSelectionEnd) onSetSelectionEnd(null);
// Ctrl+Click on empty space: start sweep select
if (e.ctrlKey && !clickedClip && !hitItem) {
e.preventDefault();
e.stopPropagation();
if (onSweepSelectStart) onSweepSelectStart(track.id, time);
return;
}
onPlayheadSet(time);
@@ -6905,6 +6908,7 @@ const App = () => {
bpmRef.current = bpm;
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
const [selectedTrackId, setSelectedTrackId] = useState('1');
const [selectedItemIds, setSelectedItemIds] = useState(new Set());
const [currentTime, setCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [selectionStart, setSelectionStart] = useState(null);
@@ -6913,6 +6917,10 @@ const App = () => {
const selectionRef = useRef({ start: null, end: null });
selectionRef.current = { start: selectionStart, end: selectionEnd };
const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track)
const [sweepSelect, setSweepSelect] = useState(null);
const isSweepingRef = useRef(false);
const sweepStartRef = useRef(0);
const sweepTrackIdRef = useRef(null);
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
const [localSelectionStart, setLocalSelectionStart] = useState(null);
const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
@@ -11611,9 +11619,94 @@ const App = () => {
};
}, [zoom]);
// Sweep Select
const handleSweepSelectStart = (trackId, startTime) => {
isSweepingRef.current = true;
sweepStartRef.current = startTime;
sweepTrackIdRef.current = trackId;
setSweepSelect({ startTime, endTime: startTime });
setSelectedItemIds(new Set());
};
// Section / MIDI Item Drag Start
const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset) => {
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset });
const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset, isDuplicate) => {
var curTracks = activeTracksRef.current || activeTracks;
var multiIds = null;
if (selectedItemIds && selectedItemIds.size > 0 && selectedItemIds.has(itemId)) {
var selArr = Array.from(selectedItemIds);
var originals = {};
curTracks.forEach(function(t) {
(t.sections || []).forEach(function(s) {
if (selArr.indexOf(s.id) >= 0) originals[s.id] = { type: 'section', start: s.start };
});
(t.midiItems || []).forEach(function(m) {
if (selArr.indexOf(m.id) >= 0) originals[m.id] = { type: 'midiItem', start: m.startTime };
});
(t.clips || []).forEach(function(c) {
var cid = c.id === 'default' ? 'default_' + t.id : c.id;
if (selArr.indexOf(cid) >= 0) originals[cid] = { type: 'clip', start: c.startTime };
});
});
if (Object.keys(originals).length > 0) multiIds = originals;
}
if (isDuplicate) {
var track = curTracks.find(function(t) { return t.id === trackId; });
if (!track) return;
if (multiIds) {
var newOriginals = {};
updateActiveTracks(function(prev) {
return prev.map(function(t) {
var updatedSections = t.sections ? t.sections.slice() : [];
var updatedMidi = t.midiItems ? t.midiItems.slice() : [];
var updatedClips = t.clips ? t.clips.slice() : [];
Object.keys(multiIds).forEach(function(oid) {
var info = multiIds[oid];
if (info.type === 'section') {
var sec = (t.sections || []).find(function(s) { return s.id === oid; });
if (sec) {
var newId = 'sec_dup_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
updatedSections.push({ ...sec, id: newId, name: sec.name + ' (Copy)' });
newOriginals[newId] = { type: 'section', start: sec.start };
}
} else if (info.type === 'midiItem') {
var mid = (t.midiItems || []).find(function(m) { return m.id === oid; });
if (mid) {
var newId = 'midi_dup_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
updatedMidi.push({ ...mid, id: newId, name: mid.name + ' (Copy)' });
newOriginals[newId] = { type: 'midiItem', start: mid.startTime };
}
} else if (info.type === 'clip') {
var clip = (t.clips || []).find(function(c) { return c.id === oid || 'default_' + t.id === oid; });
if (clip) {
var newId = 'clip_dup_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
updatedClips.push({ ...clip, id: newId, startTime: clip.startTime, name: clip.name + ' (Copy)' });
newOriginals[newId] = { type: 'clip', start: clip.startTime };
}
}
});
var changed = t.sections && t.sections.length !== updatedSections.length;
return t.id === trackId ? { ...t, sections: updatedSections, midiItems: updatedMidi, clips: updatedClips } : t;
});
});
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds: newOriginals });
} else {
var items = itemType === 'section' ? (track.sections || []) : (track.midiItems || []);
var item = items.find(function(it) { return it.id === itemId; });
if (!item) return;
var newId = itemType + '_dup_' + Date.now();
var newItem = { ...item, id: newId, name: item.name + ' (Copy)' };
updateActiveTracks(function(prev) {
return prev.map(function(t) {
if (t.id !== trackId) return t;
var updated = itemType === 'section' ? [...(t.sections || []), newItem] : [...(t.midiItems || []), newItem];
return itemType === 'section' ? { ...t, sections: updated } : { ...t, midiItems: updated };
});
});
setDraggedSectionItem({ trackId, itemType, itemId: newId, clickOffset, isDuplicate: false });
}
return;
}
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds });
};
// Section / MIDI Item Resize Start
@@ -11666,6 +11759,26 @@ const App = () => {
}
}
return prev.map(t => {
if (drag.multiIds) {
var dragOrigStart = drag.multiIds[drag.itemId] ? drag.multiIds[drag.itemId].start : 0;
var delta = newStart - dragOrigStart;
var midSections = (t.sections || []).slice();
var midMidis = (t.midiItems || []).slice();
Object.keys(drag.multiIds).forEach(function(mid) {
var info = drag.multiIds[mid];
var newVal = info.start + delta;
if (info.type === 'section') {
var idx = midSections.findIndex(function(s) { return s.id === mid; });
if (idx >= 0) midSections[idx] = { ...midSections[idx], start: Math.max(0, newVal) };
} else if (info.type === 'midiItem') {
var idx = midMidis.findIndex(function(m) { return m.id === mid; });
if (idx >= 0) midMidis[idx] = { ...midMidis[idx], startTime: Math.max(0, newVal) };
}
});
return t.id === targetTrackId
? { ...t, sections: midSections, midiItems: midMidis }
: { ...t, sections: (t.id === drag.trackId ? midSections : t.sections), midiItems: (t.id === drag.trackId ? midMidis : t.midiItems) };
}
const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []);
const updatedItems = items.filter(it => it.id !== drag.itemId);
if (t.id === targetTrackId) {
@@ -11767,6 +11880,61 @@ const App = () => {
};
}, [zoom, activeTab, sessionTabs]);
// Sweep Select mousemove/mouseup
useEffect(() => {
const handleMouseMove = e => {
if (!isSweepingRef.current) 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 = Math.max(0, mouseX / zoom - leadInMarginRef.current);
setSweepSelect(prev => prev ? { ...prev, endTime: time } : null);
};
const handleMouseUp = () => {
if (!isSweepingRef.current) return;
isSweepingRef.current = false;
const sweep = sweepSelect;
if (sweep && sweepTrackIdRef.current) {
const tId = sweepTrackIdRef.current;
const start = Math.min(sweep.startTime, sweep.endTime);
const end = Math.max(sweep.startTime, sweep.endTime);
const curTracks = activeTracksRef.current || [];
const found = new Set();
curTracks.forEach(t => {
if (t.id !== tId) return;
(t.midiItems || []).forEach(m => {
if (m.startTime < end && m.startTime + m.duration > start) {
found.add(m.id);
}
});
(t.sections || []).forEach(s => {
if (s.start < end && s.start + s.duration > start) {
found.add(s.id);
}
});
(t.clips || []).forEach(c => {
const dur = c.buffer ? c.buffer.duration / (c.speed || 1.0) : 4;
if (c.startTime < end && c.startTime + dur > start) {
const cid = c.id === 'default' ? 'default_' + t.id : c.id;
found.add(cid);
}
});
});
setSelectedItemIds(found);
setSweepSelect(null);
sweepTrackIdRef.current = 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));
@@ -16308,9 +16476,11 @@ const App = () => {
onSelectRange: handleSelectRange,
onPlayheadSet: handlePlayheadSet,
isSelected: isSelected,
onSelectTrack: setSelectedTrackId,
markers: track.markers,
onTrackLaneMouseDown: handleTrackLaneMouseDown,
selectedItemIds: selectedItemIds,
onSelectTrack: setSelectedTrackId,
markers: track.markers,
onTrackLaneMouseDown: handleTrackLaneMouseDown,
onSweepSelectStart: handleSweepSelectStart,
onContextMenu: handleContextMenu,
onClipDragStart: handleClipDragStart,
onClipStretchStart: handleClipStretchStart,
@@ -16359,7 +16529,13 @@ const App = () => {
}), /*#__PURE__*/React.createElement("div", {
className: "absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize",
onMouseDown: e => handleHandleDragStart(e, 'right')
})), track.buffer && /*#__PURE__*/React.createElement("div", {
})), sweepSelect && sweepTrackIdRef.current === track.id && /*#__PURE__*/React.createElement("div", {
className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/10 z-20 pointer-events-none",
style: {
left: `${Math.min(sweepSelect.startTime, sweepSelect.endTime) * zoom}px`,
width: `${Math.abs(sweepSelect.endTime - sweepSelect.startTime) * zoom}px`
}
}), track.buffer && /*#__PURE__*/React.createElement("div", {
className: "absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => handleSplitTrack(track.id),
File diff suppressed because one or more lines are too long