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:
+198
-22
@@ -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
@@ -503,3 +503,9 @@
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (line 11133-11142), `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: Ctrl+drag trên ruler → selection overlay xuất hiện. Shift+click vẫn extend selection.
|
||||
---
|
||||
|
||||
### [2026-07-27 21:47] Task: Ctrl+drag sweep select + multi-item drag/copy
|
||||
- **Tóm tắt thay đổi:** (1) Ctrl+drag trên empty space (track lane) → sweep select (vùng chọn màu amber), mouseup → chọn tất cả items chạm vùng sweep. (2) Selected items highlight (viền vàng dày). (3) Ctrl+drag trên section/MIDI item → duplicate item. (4) Drag trên selected item → move cả nhóm items đã chọn. (5) Ctrl+drag trên selected item → copy cả nhóm.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** Cần test: Ctrl+drag empty space → sweep overlay + selected items highlight. Click+drag selected item → cả nhóm move. Ctrl+drag selected item → copy nhóm.
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user