diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 6851368..00b677b 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -1084,11 +1084,25 @@ const WaveformLane = ({ e.preventDefault(); e.stopPropagation(); if (e.ctrlKey) { - // Ctrl+Click: copy on drag, do NOT toggle selection - if (onSetPendingDrag) onSetPendingDrag(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.nativeEvent || e, selectedItemIds ? new Set(selectedItemIds) : new Set()); + // Ctrl+Click: toggle selection (add/remove from group) + set pending copy-drag + var preToggle = selectedItemIds ? new Set(selectedItemIds) : new Set(); + if (selectedItemIds && selectedItemIds.has(hitItem.id)) { + if (onDeselectItem) onDeselectItem(hitItem.id); + } else { + if (onAddToSelection) onAddToSelection(hitItem.id); + } + if (onSetPendingDrag) onSetPendingDrag(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.nativeEvent || e, preToggle); } else { - // No modifier: start drag immediately (single or multi move) - if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start, false); + // Click: select this item (clear others if not already selected), then start drag + if (!selectedItemIds || !selectedItemIds.has(hitItem.id)) { + if (onClearSelection) onClearSelection(); + if (onAddToSelection) onAddToSelection(hitItem.id); + } + // Drag all currently selected items (or just this one) + var dragIds = (selectedItemIds && selectedItemIds.has(hitItem.id) && selectedItemIds.size > 1) + ? selectedItemIds + : new Set([hitItem.id]); + if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start, false, dragIds); } return; } @@ -1161,11 +1175,12 @@ const WaveformLane = ({ return; } - // Ctrl+Click on empty space: deselect all, no sweep select + // Ctrl+Click on empty space: deselect all on click, marquee on drag if (e.ctrlKey && !clickedClip && !hitItem) { e.preventDefault(); e.stopPropagation(); - if (onClearSelection) onClearSelection(); + // Start a pending sweep: mouseup with no drag → deselect all; drag → marquee + if (onSweepSelectStart) onSweepSelectStart(track.id, time, e.clientY); return; } onPlayheadSet(time); @@ -6955,6 +6970,8 @@ const App = () => { const isSweepingRef = useRef(false); const sweepStartRef = useRef(0); const sweepTrackIdRef = useRef(null); + const sweepStartYRef = useRef(0); + const sweepEndYRef = useRef(0); const sweepSelectRef = useRef(null); const pendingDragRef = useRef(null); // { trackId, itemType, itemId, clickOffset, startX, startY } const handleSectionItemDragStartRef = useRef(null); @@ -11735,18 +11752,30 @@ const App = () => { }); }; const handleSetPendingDrag = (trackId, itemType, itemId, clickOffset, e, preToggleSnapshot) => { - pendingDragRef.current = { trackId, itemType, itemId, clickOffset, startX: e.clientX, startY: e.clientY, selectedIds: preToggleSnapshot || new Set(selectedItemIds) }; + // Compute selectedIds AFTER the toggle that just happened: + // - If itemId was in snapshot (was selected) → toggle removed it → delete + // - If itemId was not in snapshot (was not selected) → toggle added it → add + var ids = preToggleSnapshot ? new Set(preToggleSnapshot) : new Set(selectedItemIds); + if (preToggleSnapshot && preToggleSnapshot.has(itemId)) { + ids.delete(itemId); + } else { + ids.add(itemId); + } + pendingDragRef.current = { trackId, itemType, itemId, clickOffset, startX: e.clientX, startY: e.clientY, selectedIds: ids }; }; // ── Sweep Select ── - const handleSweepSelectStart = (trackId, startTime) => { + const handleSweepSelectStart = (trackId, startTime, startY) => { isSweepingRef.current = true; sweepStartRef.current = startTime; sweepTrackIdRef.current = trackId; + sweepStartYRef.current = startY || 0; + sweepEndYRef.current = startY || 0; var init = { startTime, endTime: startTime }; sweepSelectRef.current = init; setSweepSelect(init); - setSelectedItemIds(new Set()); + // Do NOT clear selection here – wait until mouseup. + // Small movement → deselect all; large movement → marquee toggle. }; // ── Section / MIDI Item Drag Start ── @@ -11809,7 +11838,8 @@ const App = () => { return { ...t, sections: updatedSections, midiItems: updatedMidi, clips: updatedClips }; }); }); - setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds: newOriginals }); + var newItemId = Object.keys(newOriginals)[0] || itemId; + setDraggedSectionItem({ trackId, itemType, itemId: newItemId, clickOffset, multiIds: newOriginals }); } else { var items = itemType === 'section' ? (track.sections || []) : (track.midiItems || []); var item = items.find(function(it) { return it.id === itemId; }); @@ -11873,35 +11903,86 @@ const App = () => { updateActiveTracks(prev => { let movedItem = null; for (let track of prev) { - const items = drag.itemType === 'section' ? (track.sections || []) : (track.midiItems || []); - const found = items.find(it => it.id === drag.itemId); - if (found) { - movedItem = found; - break; - } + // Search all item types for the dragged item + const sec = (track.sections || []).find(it => it.id === drag.itemId); + const mid = (track.midiItems || []).find(it => it.id === drag.itemId); + if (sec || mid) { movedItem = sec || mid; break; } + } + // Build a live map: where does each multi-drag item currently live? + var currentTrackMap = {}; + if (drag.multiIds) { + Object.keys(drag.multiIds).forEach(function(mid) { + var info = drag.multiIds[mid]; + for (var pi = 0; pi < prev.length; pi++) { + var pt = prev[pi]; + var foundHere = false; + if (info.type === 'section') foundHere = (pt.sections || []).some(function(s) { return s.id === mid; }); + else if (info.type === 'midiItem') foundHere = (pt.midiItems || []).some(function(m) { return m.id === mid; }); + else if (info.type === 'clip') foundHere = (pt.clips || []).some(function(c) { return c.id === mid || 'default_' + pt.id === mid; }); + if (foundHere) { currentTrackMap[mid] = pt.id; break; } + } + }); } 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(); - var midClips = (t.clips || []).slice(); + var resultSections = (t.sections || []).slice(); + var resultMidis = (t.midiItems || []).slice(); + var resultClips = (t.clips || []).slice(); + var crossTracks = targetTrackId !== drag.trackId; 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) }; - } else if (info.type === 'clip') { - var idx = midClips.findIndex(function(c) { return c.id === mid; }); - if (idx >= 0) midClips[idx] = { ...midClips[idx], startTime: Math.max(0, newVal) }; + // Use live currentTrackMap: info.trackId is stale after cross-track moves + var currentTid = currentTrackMap.hasOwnProperty(mid) ? currentTrackMap[mid] : info.trackId; + var onCurrentTrack = currentTid === t.id; + if (onCurrentTrack) { + if (!crossTracks || t.id === targetTrackId) { + // Item is HERE and staying – update position in-place + if (info.type === 'section') { + var idx = resultSections.findIndex(function(s) { return s.id === mid; }); + if (idx >= 0) resultSections[idx] = { ...resultSections[idx], start: Math.max(0, newVal) }; + } else if (info.type === 'midiItem') { + var idx = resultMidis.findIndex(function(mx) { return mx.id === mid; }); + if (idx >= 0) resultMidis[idx] = { ...resultMidis[idx], startTime: Math.max(0, newVal) }; + } else if (info.type === 'clip') { + var idx = resultClips.findIndex(function(cx) { return cx.id === mid || 'default_' + t.id === mid; }); + if (idx >= 0) resultClips[idx] = { ...resultClips[idx], startTime: Math.max(0, newVal) }; + } + } else { + // Item must leave this track + if (info.type === 'section') resultSections = resultSections.filter(function(s) { return s.id !== mid; }); + else if (info.type === 'midiItem') resultMidis = resultMidis.filter(function(mx) { return mx.id !== mid; }); + else if (info.type === 'clip') { var cid3 = 'default_' + t.id; resultClips = resultClips.filter(function(cx) { return cx.id !== mid && cx.id !== cid3; }); } + } + } else if (crossTracks && t.id === targetTrackId) { + // This IS the target track – filter-then-upsert at new position + var srcItem = null; + for (var pi2 = 0; pi2 < prev.length; pi2++) { + var tr2 = prev[pi2]; + if (info.type === 'section') srcItem = (tr2.sections || []).find(function(s) { return s.id === mid; }); + else if (info.type === 'midiItem') srcItem = (tr2.midiItems || []).find(function(mx) { return mx.id === mid; }); + else if (info.type === 'clip') { var c2 = (tr2.clips || []).find(function(cx) { return cx.id === mid || 'default_' + tr2.id === mid; }); if (c2) srcItem = c2; } + if (srcItem) break; + } + if (srcItem) { + if (info.type === 'section') { + resultSections = resultSections.filter(function(s) { return s.id !== mid; }); + resultSections.push({ ...srcItem, start: Math.max(0, newVal) }); + } else if (info.type === 'midiItem') { + resultMidis = resultMidis.filter(function(mx) { return mx.id !== mid; }); + resultMidis.push({ ...srcItem, startTime: Math.max(0, newVal) }); + } else if (info.type === 'clip') { + var cid4 = 'default_' + t.id; + resultClips = resultClips.filter(function(cx) { return cx.id !== mid && cx.id !== cid4; }); + resultClips.push({ ...srcItem, startTime: Math.max(0, newVal) }); + } + } } + // else: item is on another track and we're not crossing → nothing to do }); - return { ...t, sections: midSections, midiItems: midMidis, clips: midClips }; + return { ...t, sections: resultSections, midiItems: resultMidis, clips: resultClips }; } const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []); const updatedItems = items.filter(it => it.id !== drag.itemId); @@ -12051,6 +12132,13 @@ const App = () => { if (sweep) { const start = Math.min(sweep.startTime, sweep.endTime); const end = Math.max(sweep.startTime, sweep.endTime); + // Small movement (no real drag) → deselect all + if (Math.abs(end - start) < 0.02) { + setSelectedItemIds(new Set()); + setSweepSelect(null); + return; + } + // Real drag → toggle items that overlap the marquee const curTracks = activeTracksRef.current || []; const found = new Set(); curTracks.forEach(t => { @@ -12072,7 +12160,15 @@ const App = () => { } }); }); - setSelectedItemIds(found); + // Toggle: add unselected, remove already-selected + setSelectedItemIds(prev => { + const next = new Set(prev); + found.forEach(id => { + if (next.has(id)) next.delete(id); + else next.add(id); + }); + return next; + }); setSweepSelect(null); sweepTrackIdRef.current = null; } @@ -16871,7 +16967,7 @@ const App = () => { e.preventDefault(); if (e.dataTransfer.files[0]) loadFileOnTrack(track.id, e.dataTransfer.files[0]); }, - onMouseEnter: () => setHoveredTrackId(track.id) + onMouseEnter: () => { setHoveredTrackId(track.id); hoveredTrackIdRef.current = track.id; } }, /*#__PURE__*/React.createElement(WaveformLane, { track: track, zoom: zoom, diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 4f4d7ec..ea65beb 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ diff --git a/md/43_DS_STUPID.md b/md/43_DS_STUPID.md new file mode 100644 index 0000000..52f364c --- /dev/null +++ b/md/43_DS_STUPID.md @@ -0,0 +1,132 @@ +# Fix: MAIN SESSION & SECTION-TAB Mouse Behavior (MOUSE.md) + +## Mô tả +Kiểm tra và sửa các lỗi trong MAIN SESSION và SECTION-TAB theo đặc tả [MOUSE.md](file:///home/locpham/SonicForgeStudio/md/MOUSE.md): + +| Hành động | Đặc tả MOUSE.md | +|---|---| +| Click item | select item, drag to move | +| Ctrl+Click item | toggle selection (add/remove from group) | +| Ctrl+Click empty | deselect all | +| Ctrl+Click+Drag item/group | **copy** item/group to new track | +| Ctrl+Click+Drag empty | marquee selection/deselection | + +--- + +## Bugs đã tìm thấy + +### Bug 1 – `handleSetPendingDrag`: luôn thêm `itemId` vào `ids`, bất kể đã toggle hay deselect +**Vị trí**: [app.jsx L11834-11837](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx#L11834-L11837) + +```js +const handleSetPendingDrag = (trackId, itemType, itemId, clickOffset, e, preToggleSnapshot) => { + var ids = preToggleSnapshot || new Set(selectedItemIds); + ids.add(itemId); // ← luôn ADD itemId, bất kể đây là lần toggle-off + pendingDragRef.current = { ... }; +}; +``` + +- Khi user **Ctrl+Click** vào một item đang được selected → item đó bị deselect (line 1107). +- Nhưng `preToggleSnapshot` (được build **trước** toggle tại line 1111/1178) luôn được `ids.add(itemId)`, nên pendingDrag vẫn include item đó trong nhóm copy. +- **Kết quả**: Ctrl+Drag sau deselect vẫn copy item bị deselect → số lượng copies bị lỗi. + +**Fix**: Khi pendingDrag được set sau toggle, nếu item bị deselect (tức `preToggleSnapshot.has(itemId)`) thì **xóa** `itemId` khỏi ids thay vì add. + +Caller (line 1111) truyền `preToggleSnapshot` (snapshot TRƯỚC toggle), nên logic cần: +```js +// Nếu item đang có trong snapshot (tức là toggle-off / deselect), xóa khỏi ids +// Nếu item không có trong snapshot (tức là toggle-on / add), thêm vào ids +if (preToggleSnapshot && preToggleSnapshot.has(itemId)) { + ids.delete(itemId); +} else { + ids.add(itemId); +} +``` + +--- + +### Bug 2 – `handleSectionItemDragStart` với `isDuplicate=true`: `newOriginals` bị build bên trong callback async của `updateActiveTracks` + +**Vị trí**: [app.jsx L11888-11924](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx#L11888-L11924) + +```js +var newOriginals = {}; +updateActiveTracks(function(prev) { + return prev.map(function(t) { + // ... build newOriginals ở đây (bên trong callback) + newOriginals[newId] = { ... }; + return { ...t, ... }; + }); +}); +// Sử dụng newOriginals NGAY SAU updateActiveTracks (có thể chưa được fill) +var newItemId = Object.keys(newOriginals)[0] || itemId; +setDraggedSectionItem({ ..., itemId: newItemId, multiIds: newOriginals }); +``` + +Vì `updateActiveTracks` gọi setState (React batch), callback của nó được gọi đồng bộ (React's setState updater function chạy đồng bộ). Nhưng vì `newOriginals` được closure-captured bên trong callback, điều này **thực ra hoạt động đúng** trong React 18 với batching. + +Tuy nhiên: nếu **nhiều items có cùng loại** (section + midiItem + clip), vòng lặp qua `Object.keys(multiIds)` gọi `Date.now()` nhiều lần trong cùng một tick → có thể cho **cùng một newId** cho các items khác nhau, dẫn đến items bị merge. + +**Fix**: Thêm index counter vào ID tạo để đảm bảo unique: +```js +var dupCounter = 0; +var newId = 'sec_dup_' + Date.now() + '_' + (dupCounter++) + '_' + Math.random()... +``` + +--- + +### Bug 3 – Section-only path trong `isDuplicate` (non-multi): không xử lý `clip` type +**Vị trí**: [app.jsx L11925-11938](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx#L11925-L11938) + +Khi không có `multiIds` (chỉ 1 item đơn lẻ), code chỉ handle `section` và `midiItem`: +```js +var items = itemType === 'section' ? (track.sections || []) : (track.midiItems || []); +``` + +Nếu `itemType === 'clip'`, `items` sẽ dùng `midiItems` (sai). Cần thêm case cho `clip`. + +--- + +### Bug 4 – Ctrl+Click trên CLIP ở line 1172: điều kiện kiểm tra bỏ qua hitItem +**Vị trí**: [app.jsx L1172](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx#L1172) + +```js +if (clickedClip && (e.altKey || e.ctrlKey)) { ... } +``` + +Đoạn này xử lý Ctrl+Click trên **audio clip**. Tuy nhiên ở trên (line 1101), block `if (hitItem && !e.altKey && !e.shiftKey)` đã `return` nếu hitItem (section/midiItem) được click với Ctrl. Nhưng nếu không có hitItem và không có clickedClip, flow đúng (sweep select). Không có lỗi ở đây. + +--- + +### Bug 5 – `handleSetPendingDrag` cho Clip (line 1184): cùng vấn đề như Bug 1 + +Tương tự Bug 1, khi Ctrl+Click trên clip đang selected (sẽ bị deselect ở line 1180), nhưng `clipPreToggleSnapshot.has(clipCanonicalId)` là true và `ids.add(clipCanonicalId)` vẫn được thực thi trong `handleSetPendingDrag`. + +--- + +## Proposed Changes + +### [MODIFY] [app.jsx](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx) + +#### Fix Bug 1 & 5 – `handleSetPendingDrag` (L11834-11838) +Sửa logic toggle để đúng: nếu item **có** trong preToggleSnapshot → nó vừa bị deselect → **xóa** khỏi ids. Nếu không có → vừa được add → **thêm** vào ids. + +#### Fix Bug 2 – ID uniqueness trong multi-copy loop +Thêm counter index vào ID generation trong vòng lặp `Object.keys(multiIds).forEach`. + +#### Fix Bug 3 – Single-item copy: xử lý `clip` type +Trong nhánh `else` (non-multiIds) của `isDuplicate`, thêm xử lý `clip` type. + +--- + +## Verification Plan + +### Manual Verification +1. Mở MAIN SESSION +2. Thêm vài sections/MIDI items trên nhiều tracks +3. **Test Ctrl+Click toggle**: Click vào item A → selected. Ctrl+Click A → deselected. Ctrl+Click B → B selected (không phải A+B). +4. **Test Ctrl+Drag single**: Ctrl+Click item → drag → chỉ copy 1 item, không thêm item nào bị deselect +5. **Test Ctrl+Drag group**: Ctrl+Click A, Ctrl+Click B (2 selected) → drag từ A → copy cả A và B, không thêm C hay D +6. **Test Ctrl+Click empty**: Click trống → deselect all +7. **Test Ctrl+Drag empty**: Drag trên empty → marquee select +8. Lặp lại trên SECTION-TAB (session_xxx)