FIX: chỉnh sửa mute và unmute realtime, sửa lỗi save dự án và chèn chung các items vào một track

This commit is contained in:
2026-08-03 17:22:19 +07:00
parent a9da813cb1
commit 8fc1c2641b
6 changed files with 418 additions and 91 deletions
+225 -61
View File
@@ -94,6 +94,26 @@ function setMasteringRoute(route, bypass) {
route.dryGain.gain.setTargetAtTime(on ? 1 : 0, t, 0.02);
}
// Realtime mute/solo: audible linear gain for a track given the full track list
// of the CURRENT context. Solo semantics: if ANY track is soloed, only soloed
// tracks are audible; muted tracks are always silent.
function computeTrackAudibleGain(trackList, track) {
if (!track) return 0;
if (track.muted) return 0;
const hasSolo = (trackList || []).some(t => t.solo);
if (hasSolo && !track.solo) return 0;
const volDb = track.volumeDb ?? 0;
return volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
}
// Apply a gain to a track node's gain with a short crossfade (click-free).
function setTrackNodeGain(node, gainLinear) {
if (!node || !node.gainNode || !audioCtx) return;
const t = audioCtx.currentTime;
node.gainNode.gain.cancelScheduledValues(t);
node.gainNode.gain.setTargetAtTime(gainLinear, t, 0.02);
}
function makeDistortionCurve(k) {
const n_samples = 44100;
const curve = new Float32Array(n_samples);
@@ -811,14 +831,14 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
React.createElement("div", {
className: "flex items-center justify-center gap-1 py-0.5 shrink-0"
}, React.createElement("button", {
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { muted: !track.muted }); },
title: "Mute",
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isMuted ? 'bg-orange-500 text-black border-orange-400' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
}, "M"), React.createElement("button", {
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
title: "Solo",
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isSoloed ? 'bg-yellow-400 text-black border-yellow-300' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
}, "S"), React.createElement("button", {
onClick: e => { e.stopPropagation(); const next = !track.muted; if (onUpdateTrack) onUpdateTrack(track.id, { muted: next }); if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(track.id, { muted: next }); },
title: "Mute",
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isMuted ? 'bg-orange-500 text-black border-orange-400' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
}, "M"), React.createElement("button", {
onClick: e => { e.stopPropagation(); const next = !track.solo; if (onUpdateTrack) onUpdateTrack(track.id, { solo: next }); if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(track.id, { solo: next }); },
title: "Solo",
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isSoloed ? 'bg-yellow-400 text-black border-yellow-300' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
}, "S"), React.createElement("button", {
onClick: e => {
e.stopPropagation();
const next = !track.masteringBypass;
@@ -1179,6 +1199,7 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
var trackColor = track.color || '#06b6d4';
var isMuted = track.muted;
var isSoloed = track.solo;
var isBypassed = track.masteringBypass;
var isArmed = track.isArmed;
var trackName = track.name || 'Track ' + (index + 1);
var isMicActive = track.inputSource?.deviceType === 'MICROPHONE';
@@ -1305,18 +1326,25 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
/* Right Button Stack */
React.createElement("div", { className: "w-7 flex flex-col justify-between text-[8px] font-bold shrink-0" },
React.createElement("button", {
onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { muted: !track.muted }); },
onClick: function(e) { e.stopPropagation(); var next = !track.muted; if (onUpdateTrack) onUpdateTrack(track.id, { muted: next }); if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(track.id, { muted: next }); },
className: "btn-daw h-[24px] rounded text-amber-500 font-extrabold flex items-center justify-center" + (isMuted ? " btn-mute-active" : ""),
title: "Mute Track"
}, "M"),
React.createElement("button", {
onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
onClick: function(e) { e.stopPropagation(); var next = !track.solo; if (onUpdateTrack) onUpdateTrack(track.id, { solo: next }); if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(track.id, { solo: next }); },
className: "btn-daw h-[24px] rounded text-yellow-400 font-extrabold flex items-center justify-center" + (isSoloed ? " btn-solo-active" : ""),
title: "Solo Track"
}, "S"),
React.createElement("button", {
className: "btn-daw h-[24px] rounded text-emerald-400 flex items-center justify-center",
title: "Routing Matrix"
onClick: function(e) {
e.stopPropagation();
var next = !track.masteringBypass;
if (onUpdateTrack) onUpdateTrack(track.id, { masteringBypass: next });
// Live re-route: bypassed channel skips FX + mastering chain at Main out.
if (window.__setTrackMasteringBypass) window.__setTrackMasteringBypass(track.id, next);
},
className: "btn-daw h-[24px] rounded text-sky-400 flex items-center justify-center" + (isBypassed ? " btn-bypass-active bg-sky-500/20" : ""),
title: "Bypass: track KHÔNG qua FX + mastering ở Main out"
}, React.createElement("i", { className: "fa-solid fa-bars-staggered text-[8px]" })),
React.createElement("button", {
className: "btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]",
@@ -1399,6 +1427,7 @@ const WaveformLane = ({
onDeselectItem,
onAddToSelection,
onSetPendingDrag,
onSetPendingDragMove,
onSetSelectionMode,
onSetSelectionStart,
onSetSelectionEnd,
@@ -2110,7 +2139,10 @@ const WaveformLane = ({
}
if (onSetPendingDrag) onSetPendingDrag(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.nativeEvent || e, preToggle);
} else {
// Click: select this item (clear others if not already selected), then start drag
// Click: select this item (clear others if not already selected).
// Drag only starts after a small movement threshold (5px) click alone
// just selects; this unifies section/MIDI/clip behavior and stops
// accidental moves from mouse jitter.
if (!selectedItemIds || !selectedItemIds.has(hitItem.id)) {
if (onClearSelection) onClearSelection();
if (onAddToSelection) onAddToSelection(hitItem.id);
@@ -2119,7 +2151,7 @@ const WaveformLane = ({
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);
if (onSetPendingDragMove) onSetPendingDragMove(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.nativeEvent || e, dragIds);
}
return;
}
@@ -2171,24 +2203,49 @@ const WaveformLane = ({
return;
}
// Check for click drag clip (Alt to move, Ctrl to duplicate)
if (clickedClip && (e.altKey || e.ctrlKey)) {
// Check for click drag clip (plain click = move, Alt = sweep-select duration, Ctrl = duplicate)
if (clickedClip && e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
if (e.ctrlKey) {
// Ctrl+Click: toggle selection, store pending drag w/ pre-toggle snapshot
var clipCanonicalId = clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id;
var clipPreToggleSnapshot = selectedItemIds ? new Set(selectedItemIds) : new Set();
if (selectedItemIds && selectedItemIds.has(clipCanonicalId)) {
if (onDeselectItem) onDeselectItem(clipCanonicalId);
} else if (onAddToSelection) {
onAddToSelection(clipCanonicalId);
}
if (onSetPendingDrag) onSetPendingDrag(track.id, 'clip', clipCanonicalId, time - clickedClip.startTime, e.nativeEvent || e, clipPreToggleSnapshot);
} else {
// Alt+Click: move immediately
if (onClipDragStart) onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, false);
// Ctrl+Click: toggle selection, store pending drag w/ pre-toggle snapshot
var clipCanonicalId = clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id;
var clipPreToggleSnapshot = selectedItemIds ? new Set(selectedItemIds) : new Set();
if (selectedItemIds && selectedItemIds.has(clipCanonicalId)) {
if (onDeselectItem) onDeselectItem(clipCanonicalId);
} else if (onAddToSelection) {
onAddToSelection(clipCanonicalId);
}
if (onSetPendingDrag) onSetPendingDrag(track.id, 'clip', clipCanonicalId, time - clickedClip.startTime, e.nativeEvent || e, clipPreToggleSnapshot);
return;
}
// Alt+Click on a clip: sweep-select duration (the OLD plain click+drag
// behavior moved here). Plain click+drag now MOVES the clip like
// section/MIDI items.
if (clickedClip && e.altKey) {
e.preventDefault();
e.stopPropagation();
onPlayheadSet(time);
if (onTrackLaneMouseDown) {
onTrackLaneMouseDown(track.id, time, e);
}
return;
}
// Plain click on a clip: select it; drag after a small movement
// threshold moves it (unified with section/MIDI items).
if (clickedClip) {
e.preventDefault();
e.stopPropagation();
var clipCanonicalId2 = clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id;
if (!selectedItemIds || !selectedItemIds.has(clipCanonicalId2)) {
if (onClearSelection) onClearSelection();
if (onAddToSelection) onAddToSelection(clipCanonicalId2);
}
var clipDragIds = (selectedItemIds && selectedItemIds.has(clipCanonicalId2) && selectedItemIds.size > 1)
? selectedItemIds
: new Set([clipCanonicalId2]);
if (onSetPendingDragMove) onSetPendingDragMove(track.id, 'clip', clipCanonicalId2, time - clickedClip.startTime, e.nativeEvent || e, clipDragIds);
return;
}
@@ -7972,7 +8029,10 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI";
const items = [];
if (trackType === "AUDIO" && t.clips) {
// Serialize EVERY item type present on the track (a track can hold audio
// clips + MIDI items + section items at once). The old if/else-if chain
// dropped all but one type per track silent data loss on save.
if (t.clips && t.clips.length > 0) {
t.clips.forEach(c => {
const durationSec = c.buffer ? c.buffer.duration : 4.0;
const clipFileId = c.serverFileId || t.serverFileId;
@@ -7992,7 +8052,8 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
}
});
});
} else if (trackType === "MIDI" && t.midiItems) {
}
if (t.midiItems && t.midiItems.length > 0) {
t.midiItems.forEach(m => {
items.push({
id: m.id,
@@ -8014,7 +8075,8 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
}
});
});
} else if (trackType === "SECTION" && t.sections) {
}
if (t.sections && t.sections.length > 0) {
t.sections.forEach(s => {
items.push({
id: s.id,
@@ -11783,11 +11845,55 @@ const App = () => {
if (node && node.route) setMasteringRoute(node.route, !!bypass);
};
// Keep the routing map in sync with the tracks state (loads, undo, AI ops).
useEffect(() => {
(tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; });
(sessionTabs || []).forEach(st => (st.tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; }));
}, [tracks, sessionTabs]);
// Realtime mute/solo: applies the (patched) mute/solo state to every active
// track node in the current context. Called from the M/S buttons of the
// mixer strips + track strips so toggling muting/soloing affects the items
// already playing on that track immediately (20ms crossfade, no click).
const applyAllTrackMuteSolo = (trackId, patch) => {
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
const effective = (trackId && patch) ? list.map(t => t.id === trackId ? { ...t, ...patch } : t) : list;
let becameAudible = false;
effective.forEach(t => {
const audible = computeTrackAudibleGain(effective, t) > 0;
const wasAudible = trackAudibleRef.current[t.id] !== false;
const node = activeTrackNodesRef.current[t.id];
if (node) setTrackNodeGain(node, computeTrackAudibleGain(effective, t));
// MIDI items: FluidSynth mixes ALL channels into ONE shared gain node, so
// the per-track gain cannot silence them. Every MIDI track owns a
// dedicated channel (ensureTrackMidiChannel) mute/solo via CC7 (channel
// volume) applies realtime, even to notes already sounding.
if ((t.midiItems && t.midiItems.length > 0) || t.type === 'MIDI') {
try {
if (window.SonicSF && typeof window.SonicSF.controllerChange === 'function') {
const ch = ensureTrackMidiChannel(t, effective);
window.SonicSF.controllerChange(ch, 7, audible ? 100 : 0);
}
} catch (e) {}
}
if (audible && !wasAudible) becameAudible = true;
trackAudibleRef.current[t.id] = audible;
});
// A track that just became audible may have had its audio sources dropped
// by a loop restart while it was inaudible (startTrackPlayback skips
// muted/soloed tracks, so unmute alone cannot resurrect them). Re-schedule
// from the current playhead the same approach toggleTrackSoloEvaluate
// already uses for solo.
if (becameAudible && isPlaying && recordingStateRef.current !== 'RECORDING' && activeTracksRef.current && activeTracksRef.current.length) {
try {
stopAllPlayback();
const ctx = getAudioContext();
const resumeAt = currentTimeRef.current || 0;
startOffsetTimeRef.current = resumeAt;
startAudioTimeRef.current = ctx.currentTime;
startBufferOffsetRef.current = resumeAt;
setIsPlaying(true);
startTrackPlayback(resumeAt);
} catch (e) {
console.warn('mute/solo resume error:', e);
}
}
};
window.__applyTrackMuteSolo = applyAllTrackMuteSolo;
window.__toggleMediaExplorerRef = function() {
setShowMediaExplorer(function(p) {
const next = !p;
@@ -12274,6 +12380,42 @@ const App = () => {
activeTracksRef.current = activeTracks;
const sessionTabsRef = useRef(sessionTabs);
sessionTabsRef.current = sessionTabs;
// Track mute/solo/volume signature: only re-apply gains when something that
// affects audibility actually changed (loads, undo, AI ops, section tabs).
const trackMuteSoloSigRef = useRef({});
// Last-known audibility per track id (used to detect inaudibleaudible
// transitions that need a playback re-schedule).
const trackAudibleRef = useRef({});
// Keep the mastering-bypass routing map + realtime mute/solo in sync with the
// tracks state (loads, undo, AI ops). Placed AFTER the tracks/subTabs/
// sessionTabs declarations (TDZ-safe).
useEffect(() => {
const all = [...(tracks || []), ...(sessionTabs || []).reduce((acc, s) => acc.concat(s.tracks || []), [])];
all.forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; });
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : all;
list.forEach(t => {
const sig = (t.muted ? '1' : '0') + (t.solo ? '1' : '0') + ':' + (t.volumeDb ?? 0);
if (trackMuteSoloSigRef.current[t.id] === sig) return;
trackMuteSoloSigRef.current[t.id] = sig;
const audible = computeTrackAudibleGain(list, t) > 0;
const node = activeTrackNodesRef.current[t.id];
if (node) setTrackNodeGain(node, computeTrackAudibleGain(list, t));
// MIDI tracks: mirror the gain decision onto the channel CC7 volume so
// FluidSynth-rendered notes respect mute/solo too.
if ((t.midiItems && t.midiItems.length > 0) || t.type === 'MIDI') {
try {
if (window.SonicSF && typeof window.SonicSF.controllerChange === 'function') {
const ch = ensureTrackMidiChannel(t, list);
window.SonicSF.controllerChange(ch, 7, audible ? 100 : 0);
}
} catch (e) {}
}
trackAudibleRef.current[t.id] = audible;
});
}, [tracks, sessionTabs]);
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
const midiVuActivityRef = useRef({});
@@ -12398,23 +12540,23 @@ const App = () => {
}));
if (hasLoadedAny) {
setTracks(prev => {
const merged = [...updatedTracks];
(prev || []).forEach((pt, i) => {
if (!merged[i]) merged[i] = pt;
else {
merged[i] = { ...merged[i] };
merged[i].clips = (pt.clips || []).map((pc, j) => {
if (merged[i].clips && merged[i].clips[j] && (merged[i].clips[j].buffer || pc.buffer)) {
return { ...pc, buffer: pc.buffer || merged[i].clips[j].buffer };
}
if ((pc.buffer || (merged[i].clips && merged[i].clips[j] && merged[i].clips[j].buffer))) {
return pc;
}
return merged[i].clips && merged[i].clips[j] ? merged[i].clips[j] : pc;
});
}
// Merge by TRACK ID (not array index): if the state changed between the
// fetch start and now (e.g. another project opened), index-based merging
// would scramble tracks and drop items into the wrong track. updatedTracks
// is authoritative; patch clip buffers from prev by matching clip ids.
const prevById = new Map((prev || []).map(pt => [pt.id, pt]));
return updatedTracks.map(ut => {
const pt = prevById.get(ut.id);
if (!pt) return ut;
const ptClips = pt.clips || [];
const utClips = ut.clips || [];
const mergedClips = ptClips.map(pc => {
const uc = utClips.find(c => c.id === pc.id);
if (uc) return { ...pc, buffer: pc.buffer || uc.buffer };
return pc;
});
return { ...ut, clips: mergedClips };
});
return merged;
});
setSessionTabs(prev => prev.map(st => ({
...st,
@@ -15353,6 +15495,8 @@ const App = () => {
pannerNode,
source
};
// Realtime mute/solo for the newly created playback chain.
if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(st.trackId, null);
startOffsetTimeRef.current = offsetWallTime;
startBufferOffsetRef.current = offsetBuffer;
startAudioTimeRef.current = context.currentTime;
@@ -15627,11 +15771,12 @@ const App = () => {
const analyserNode = context.createAnalyser();
analyserNode.fftSize = 256;
pannerNode.connect(analyserNode);
// Dual mastering route: routeGain -> mastering chain (normal), dryGain ->
// dry bus (bypass). Live-toggled via setMasteringRoute(node.route, ...).
// Dual mastering route: routeGain -> mastering chain (normal, post-FX),
// dryGain -> dry bus (bypass, tapped PRE-FX so the bypassed channel skips
// BOTH the track FX chain and the mastering chain at Main out).
const route = createMasteringRoute(context, track, masterBus);
analyserNode.connect(route.routeGain);
analyserNode.connect(route.dryGain);
gainNode.connect(route.dryGain);
let fxStopFn;
if (track.fxType === 'chorus') {
@@ -15648,6 +15793,10 @@ const App = () => {
gainNode.connect(pannerNode);
}
node = { gainNode, pannerNode, fxStopFn, analyserNode, route };
// Realtime mute/solo: apply the track's current mute/solo/volume state to
// the fresh node so items of muted/soloed tracks start correctly.
const trackList = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : [track];
setTrackNodeGain(node, computeTrackAudibleGain(trackList, track));
activeTrackNodesRef.current[track.id] = node;
}
return node.gainNode;
@@ -16912,6 +17061,8 @@ const App = () => {
isDuplicate: false
});
};
const handleClipDragStartRef = useRef(null);
handleClipDragStartRef.current = handleClipDragStart;
const stretchedClipRef = useRef(null);
stretchedClipRef.current = stretchedClip;
const handleClipStretchStart = (trackId, clipId, clickTime) => {
@@ -17222,7 +17373,14 @@ const App = () => {
} else {
ids.add(itemId);
}
pendingDragRef.current = { trackId, itemType, itemId, clickOffset, startX: e.clientX, startY: e.clientY, selectedIds: ids };
pendingDragRef.current = { trackId, itemType, itemId, clickOffset, startX: e.clientX, startY: e.clientY, selectedIds: ids, duplicate: true };
};
// Plain click on an item: select only; the real drag (move) starts after a
// 5px movement threshold. Unifies section/MIDI/clip behavior and prevents
// accidental moves from mouse jitter.
const handleSetPendingDragMove = (trackId, itemType, itemId, clickOffset, e, dragIds) => {
pendingDragRef.current = { trackId, itemType, itemId, clickOffset, startX: e.clientX, startY: e.clientY, selectedIds: dragIds, duplicate: false };
};
// Sweep Select
@@ -17321,8 +17479,8 @@ const App = () => {
}
} else {
var curTrk = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === trackId) : null;
var its = itemType === 'section' ? (curTrk?.sections || []) : (curTrk?.midiItems || []);
var it = its.find(function(x) { return x.id === itemId; });
var its = itemType === 'section' ? (curTrk?.sections || []) : (itemType === 'clip' ? (curTrk?.clips || []) : (curTrk?.midiItems || []));
var it = its.find(function(x) { return x.id === itemId || (itemType === 'clip' && itemId === 'default_' + curTrk?.id && x.id === 'default'); });
var origPos = it ? (itemType === 'section' ? it.start : it.startTime) : 0;
var beforeSnap = captureAllTracksSnapshot();
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds, originalPositions: { [itemId]: origPos }, beforeSnap });
@@ -17550,7 +17708,7 @@ const App = () => {
};
}, [zoom, activeTab, sessionTabs]);
// Pending drag: Ctrl+click toggles selection; mousemove > threshold starts copy-drag
// Pending drag: click selects; mousemove > 5px threshold starts the real drag
useEffect(() => {
const handleMouseMove = e => {
var pd = pendingDragRef.current;
@@ -17559,7 +17717,12 @@ const App = () => {
if (Math.abs(dx) > 5) {
var pdSnap = pendingDragRef.current;
pendingDragRef.current = null;
if (handleSectionItemDragStartRef.current) handleSectionItemDragStartRef.current(pdSnap.trackId, pdSnap.itemType, pdSnap.itemId, pdSnap.clickOffset, true, pdSnap.selectedIds);
if (pdSnap.itemType === 'clip' && !pdSnap.duplicate && (!pdSnap.selectedIds || pdSnap.selectedIds.size <= 1)) {
// Single clip move clip drag machinery
if (handleClipDragStartRef.current) handleClipDragStartRef.current(pdSnap.trackId, pdSnap.itemId, pdSnap.clickOffset, false);
} else if (handleSectionItemDragStartRef.current) {
handleSectionItemDragStartRef.current(pdSnap.trackId, pdSnap.itemType, pdSnap.itemId, pdSnap.clickOffset, !!pdSnap.duplicate, pdSnap.selectedIds);
}
}
};
document.addEventListener('mousemove', handleMouseMove);
@@ -22944,6 +23107,7 @@ const App = () => {
onDeselectItem: handleDeselectItem,
onAddToSelection: handleAddToSelection,
onSetPendingDrag: handleSetPendingDrag,
onSetPendingDragMove: handleSetPendingDragMove,
onContextMenu: handleContextMenu,
onClipDragStart: handleClipDragStart,
onClipStretchStart: handleClipStretchStart,
File diff suppressed because one or more lines are too long