fix: piano roll zoom/scroll + AI copilot + SF cache + auto-scroll drag

This commit is contained in:
2026-07-25 07:37:27 +07:00
parent 1c0d35f69b
commit b04a76ebfb
6 changed files with 406 additions and 101 deletions
+289 -68
View File
@@ -438,8 +438,9 @@ const WaveformLane = ({
// Grid lines based on Snap value
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
ctx.lineWidth = 1;
const tStart = scrollLeft / zoom;
const tEnd = (scrollLeft + drawWidth) / zoom;
const PADDING_LEFT = 2; // seconds of empty space on left edge
const tStart = scrollLeftVal / zoom - PADDING_LEFT;
const tEnd = (scrollLeftVal + drawWidth) / zoom;
let gridSpacing = 1.0;
if (snapValue && snapValue !== 'free') {
const beatDuration = 60 / parseFloat(bpm || 120);
@@ -1214,8 +1215,9 @@ const TempoTrackLane = ({
ctx.fillRect(0, 0, drawWidth, height);
const beatDuration = 60 / bpm;
const barDuration = beatDuration * 4;
const tStart = scrollLeft / zoom;
const tEnd = (scrollLeft + drawWidth) / zoom;
const PADDING_LEFT = 2; // seconds of empty space on left edge
const tStart = scrollLeftVal / zoom - PADDING_LEFT;
const tEnd = (scrollLeftVal + drawWidth) / zoom;
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
for (let t = firstBeat; t <= tEnd; t += beatDuration) {
const beatNum = Math.floor(t / beatDuration) + 1;
@@ -4424,14 +4426,19 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const pixelsPerBeat = rollZoom;
const timeSigNum = 4;
const noteMaxBeat = (st.notes || []).reduce((max, n) => Math.max(max, (n.start_beat || 0) + (n.duration_beats || 1)), 0);
const totalBeats = Math.max(noteMaxBeat + 16, 64); // at least 64 beats (16 bars) for scrolling
const drawWidth = totalBeats * pixelsPerBeat;
const [notes, setNotes] = React.useState(st.notes || []);
const [selectedNoteIds, setSelectedNoteIds] = React.useState([]);
const [selectionMarquee, setSelectionMarquee] = React.useState(null); // { startBeat, startPitch, currentBeat, currentPitch }
const [draggedNote, setDraggedNote] = React.useState(null); // { mode: 'move'|'resize', idx, startOffsetBeat, originalStart }
const [hoveredResizeIdx, setHoveredResizeIdx] = React.useState(-1);
const [rollBeats, setRollBeats] = React.useState(Math.max(noteMaxBeat + 16, 64));
const rollBeatsRef = React.useRef(rollBeats);
rollBeatsRef.current = rollBeats;
const totalBeats = Math.max(rollBeats, noteMaxBeat + 16, 64); // at least 64 beats (16 bars) for scrolling
const [gridViewWidth, setGridViewWidth] = React.useState(800);
const drawWidth = totalBeats * pixelsPerBeat;
const cssWidth = Math.max(drawWidth, gridViewWidth);
const [notes, setNotes] = React.useState(st.notes || []);
const [selectedNoteIds, setSelectedNoteIds] = React.useState([]);
// Undo/redo stacks
const undoStackRef = React.useRef([]);
@@ -4529,42 +4536,28 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
};
}, []);
// Shift + Scroll event listener to adjust velocity
// Shift + Scroll event listener: fastforward playhead + play notes
React.useEffect(() => {
const handleCanvasWheel = (e) => {
if (e.shiftKey) {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
// Find note under cursor
const noteUnderCursor = notes.find(n => {
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
});
const delta = e.deltaY < 0 ? 0.05 : -0.05;
if (selectedNoteIds.length > 0) {
setNotes(prev => prev.map(n => {
if (!selectedNoteIds.includes(n.id)) return n;
const newVel = Math.max(0.1, Math.min(1.0, (n.velocity ?? 0.8) + delta));
return { ...n, velocity: newVel };
}));
} else if (noteUnderCursor) {
setNotes(prev => prev.map(n => {
if (n.id !== noteUnderCursor.id) return n;
const newVel = Math.max(0.1, Math.min(1.0, (n.velocity ?? 0.8) + delta));
if (window.SonicSF) {
const ctx = getAudioContext();
window.SonicSF.playNote(n.pitch, newVel * 127, 300, ctx.currentTime, st.instrumentProgram, null);
}
return { ...n, velocity: newVel };
}));
const scrollDelta = e.deltaY;
const beatSec = 60.0 / (parseInt(bpm) || 120);
const step = scrollDelta < 0 ? -0.25 : 0.25; // 1/4 beat per notch
const currentBeat = (st.currentTime || 0) / beatSec;
const maxBeats = totalBeats;
const newBeat = Math.max(0, Math.min(maxBeats, currentBeat + step));
const newTime = newBeat * beatSec;
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: newTime } : s));
// Play notes at the new beat position
if (window.SonicSF) {
const ctx = getAudioContext();
const playing = notes.filter(n =>
newBeat >= n.start_beat && newBeat < n.start_beat + n.duration_beats
);
playing.forEach(n => {
window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, st.instrumentProgram, null);
});
}
}
};
@@ -4578,7 +4571,19 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
canvas.removeEventListener('wheel', handleCanvasWheel);
}
};
}, [notes, selectedNoteIds, pixelsPerBeat]);
}, [notes, st.currentTime, pixelsPerBeat, st.id, totalBeats, bpm]);
// Track grid container width for zoom fill
React.useEffect(() => {
const el = gridScrollRef.current;
if (!el) return;
const ro = new ResizeObserver(entries => {
for (const entry of entries) setGridViewWidth(entry.contentRect.width);
});
ro.observe(el);
setGridViewWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
React.useEffect(() => {
const canvas = canvasRef.current;
@@ -4586,7 +4591,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const h = 128 * NoteHeight;
canvas.width = drawWidth * dpr;
const canvasW = Math.max(drawWidth, gridViewWidth);
canvas.width = canvasW * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
@@ -4614,8 +4620,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
else if (snapVal === '1/16') snapBeats = 0.25;
else if (snapVal === '1/32') snapBeats = 0.125;
for (let beat = 0; beat <= totalBeats; beat += snapBeats) {
const maxBeatPx = drawWidth;
const gridEndBeat = Math.max(totalBeats, Math.ceil(canvasW / pixelsPerBeat) + 4);
for (let beat = 0; beat <= gridEndBeat; beat += snapBeats) {
const x = beat * pixelsPerBeat;
if (x > drawWidth) break;
const isBar = beat % timeSigNum === 0;
ctx.strokeStyle = isBar ? '#444450' : '#2d2d35';
ctx.lineWidth = isBar ? 1.2 : 0.6;
@@ -4688,7 +4697,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const h = 80;
canvas.width = drawWidth * dpr;
canvas.width = Math.max(drawWidth, gridViewWidth) * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
@@ -4748,6 +4757,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const beat = x / pixelsPerBeat;
const pitch = 127 - Math.floor(y / NoteHeight);
if (scaleMenuPos) setScaleMenuPos(null);
// Right click -> Quick delete note or start erase sweep
if (e.button === 2) {
e.preventDefault();
@@ -4867,7 +4878,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
nextSelectedIds = [clickedNote.id];
setSelectedNoteIds(nextSelectedIds);
} else {
nextSelectedIds = selectedNoteIds;
// Note already selected -- only play preview, don't drag
if (window.SonicSF) {
const ctx = getAudioContext();
window.SonicSF.playNote(clickedNote.pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null);
}
return;
}
pushToUndo(notes);
notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes));
@@ -4893,7 +4909,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const noteId = 'note_' + Date.now() + Math.random().toString(36).substr(2, 5);
const newNote = {
id: noteId,
pitch: pitch,
pitch: snapPitchToScale(pitch, selectedScale),
start_beat: start,
duration_beats: initialDur,
velocity: 0.8,
@@ -4905,12 +4921,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
mode: 'draw',
idx: -1,
startOffsetBeat: start,
startOffsetPitch: pitch,
startOffsetPitch: snapPitchToScale(pitch, selectedScale),
drawNoteId: noteId,
drawDuration: initialDur,
visitedPitches: [pitch],
visitedPitches: [snapPitchToScale(pitch, selectedScale)],
initialBeat: start,
initialPitch: pitch
initialPitch: snapPitchToScale(pitch, selectedScale)
});
// Play the note with SoundFont
if (window.SonicSF) {
@@ -4983,8 +4999,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
return { ...n, duration_beats: newDur };
}));
}
if (!visited.includes(pitch)) {
const newPitches = [...visited, pitch];
const snappedPitch = snapPitchToScale(pitch, selectedScale);
if (!visited.includes(snappedPitch)) {
const newPitches = [...visited, snappedPitch];
const totalSpan = Math.max(0.125, beat - draggedNote.initialBeat);
const perNoteDur = totalSpan / newPitches.length;
const brushIds = draggedNote.brushIds || [];
@@ -5080,6 +5097,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
e.preventDefault();
};
const ccDragRef = React.useRef(null);
const handleCCMouseDown = (e) => {
const canvas = ccCanvasRef.current;
if (!canvas) return;
@@ -5102,16 +5121,64 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
});
}
if (noteIdx !== -1) {
const val = Math.max(0, Math.min(1, (h - y) / h));
setNotes(prev => prev.map((n, idx) => {
if (idx !== noteIdx) return n;
if (ccMode === 'pan') {
return { ...n, pan: (val - 0.5) * 2.0 };
} else {
return { ...n, velocity: val };
}
const val = Math.max(0, Math.min(1, (h - y) / h));
const paintNote = (idx, v) => {
if (idx === -1) return;
setNotes(prev => prev.map((n, i) => {
if (i !== idx) return n;
if (ccMode === 'pan') { return { ...n, pan: (v - 0.5) * 2.0 }; }
return { ...n, velocity: v };
}));
};
if (e.ctrlKey) {
if (noteIdx !== -1) paintNote(noteIdx, val);
ccDragRef.current = { active: true, lastBeat: beat, lastPainted: noteIdx !== -1 ? [noteIdx] : [] };
return;
}
if (noteIdx !== -1) paintNote(noteIdx, val);
};
const handleCCMouseMove = (e) => {
if (!ccDragRef.current || !ccDragRef.current.active) return;
const canvas = ccCanvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const h = rect.height;
const beat = x / pixelsPerBeat;
const val = Math.max(0, Math.min(1, (h - y) / h));
const drag = ccDragRef.current;
const painted = drag.lastPainted || [];
const candidateIdx = notes.findIndex(n => beat >= n.start_beat && beat <= n.start_beat + n.duration_beats);
if (candidateIdx !== -1 && !painted.includes(candidateIdx)) {
setNotes(prev => prev.map((n, i) => {
if (i !== candidateIdx) return n;
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val };
}));
drag.lastPainted = [...painted, candidateIdx];
} else if (candidateIdx === -1) {
let nearest = -1;
let minDist = Infinity;
notes.forEach((n, idx) => {
const center = n.start_beat + n.duration_beats / 2;
const d = Math.abs(center - beat);
if (d < minDist) { minDist = d; nearest = idx; }
});
if (nearest !== -1 && !painted.includes(nearest)) {
setNotes(prev => prev.map((n, i) => {
if (i !== nearest) return n;
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val };
}));
drag.lastPainted = [...painted, nearest];
}
}
};
@@ -5152,6 +5219,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
if (rulerScrollRef.current) {
rulerScrollRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
const el = e.currentTarget;
const THRESHOLD = 200;
if (el.scrollLeft + el.clientWidth >= el.scrollWidth - THRESHOLD) {
const newBeats = rollBeatsRef.current + 16;
setRollBeats(newBeats);
}
};
const handleKeybedScroll = (e) => {
if (gridScrollRef.current) {
@@ -5159,9 +5232,124 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
}
};
const SCALES = {
"None": null,
"Major": [0, 2, 4, 5, 7, 9, 11],
"Minor": [0, 2, 3, 5, 7, 8, 10],
"Pentatonic": {
"Chinese": [0, 2, 4, 7, 9],
"Vietnam": [0, 2, 6, 7, 10],
"India": [0, 2, 5, 7, 9],
"Japan": [0, 2, 5, 7, 9],
"Africa": [0, 3, 5, 7, 10]
},
"Blues": [0, 3, 5, 6, 7, 10],
"Dorian": [0, 2, 3, 5, 7, 9, 10],
"Phrygian": [0, 1, 3, 5, 7, 8, 10],
"Lydian": [0, 2, 4, 6, 7, 9, 11],
"Mixolydian": [0, 2, 4, 5, 7, 9, 10],
"Locrian": [0, 1, 3, 5, 6, 8, 10]
};
const [selectedScale, setSelectedScale] = React.useState(null);
const [scaleMenuPos, setScaleMenuPos] = React.useState(null);
const [aiPrompt, setAiPrompt] = React.useState('');
const [aiLoading, setAiLoading] = React.useState(false);
const snapPitchToScale = (pitch, scale) => {
if (!scale) return pitch;
const octave = Math.floor(pitch / 12);
const noteInOctave = pitch % 12;
if (scale.includes(noteInOctave)) return pitch;
let best = noteInOctave;
let minDist = 12;
scale.forEach(s => { const dist = Math.abs(s - noteInOctave); if (dist < minDist) { minDist = dist; best = s; } });
return octave * 12 + best;
};
const handleAIPrompt = async () => {
const prompt = aiPrompt.trim();
if (!prompt || !window.AIGateway) return;
setAiLoading(true);
try {
const result = await window.AIGateway.executeAIPrompt({
prompt: 'Generate MIDI notes for a piano roll. Return ONLY a JSON array: [{pitch(0-127), start_beat, duration_beats, velocity(0.0-1.0)}]. ' + prompt,
provider: 'openai',
model: 'gpt-4o',
systemInstruction: 'You are a MIDI composer. Output ONLY valid JSON array of notes. No markdown, no explanation. Use 4/4 time. pitch=C4=60, D4=62, E4=64, F4=65, G4=67, A4=69, B4=71, C5=72.'
});
let notesData = null;
if (result.textResponse) {
try {
const cleaned = result.textResponse.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
notesData = JSON.parse(cleaned);
} catch (e1) {}
}
if (!notesData && result.functionCalls) {
for (const fc of result.functionCalls) {
if (fc.arguments && fc.arguments.notes) { notesData = fc.arguments.notes; break; }
}
}
if (Array.isArray(notesData) && notesData.length > 0) {
const newNotes = notesData.map((n, i) => ({
id: 'note_ai_' + Date.now() + '_' + i,
pitch: Math.max(0, Math.min(127, n.pitch || 60)),
start_beat: Math.max(0, parseFloat(n.start_beat) || 0),
duration_beats: Math.max(0.125, parseFloat(n.duration_beats) || 0.25),
velocity: Math.max(0.1, Math.min(1.0, n.velocity ?? 0.8)),
pan: 0.0
}));
pushToUndo(notes);
setNotes(prev => [...prev, ...newNotes]);
setSelectedNoteIds(newNotes.map(n => n.id));
if (window.SonicSF && newNotes.length > 0) {
const ctx = getAudioContext();
newNotes.forEach(n => window.SonicSF.playNote(n.pitch, n.velocity * 127, 300, ctx.currentTime + (n.start_beat * 0.01), st.instrumentProgram, null));
}
}
} catch (err) {
console.error('AI Piano Roll error:', err);
}
setAiLoading(false);
};
const renderScaleContextMenu = () => {
const closeMenu = () => setScaleMenuPos(null);
const items = [];
const isSameScale = (a, b) => { if (!a || !b) return a === b; if (a.length !== b.length) return false; return a.every((v,i)=>v===b[i]); };
const pushItem = (label, onClick, indent) => {
const isActive = onClick._scale && isSameScale(onClick._scale, selectedScale);
items.push(React.createElement("div", {
key: label,
onClick: () => { onClick(); closeMenu(); },
className: "px-3 py-1.5 text-xs cursor-pointer hover:bg-amber-600 hover:text-white whitespace-nowrap " + (indent ? "pl-6 " : "") + (isActive ? "bg-amber-800/40 text-amber-300" : "text-zinc-300")
}, label));
};
Object.keys(SCALES).forEach(key => {
const val = SCALES[key];
if (val === null) { pushItem("None", () => setSelectedScale(null)); return; }
if (Array.isArray(val)) {
pushItem(key, () => setSelectedScale(val));
} else {
const parentKey = key;
const isOpen = scaleMenuPos && scaleMenuPos.parentKey === parentKey;
pushItem(key + " ▸", () => { setScaleMenuPos({ x: scaleMenuPos.x + 120, y: scaleMenuPos.y, parentKey }); }, false);
if (isOpen) {
Object.keys(val).forEach(subKey => {
pushItem(subKey, () => setSelectedScale(val[subKey]), true);
});
}
}
});
return React.createElement("div", {
style: { position: "fixed", left: scaleMenuPos.x, top: scaleMenuPos.y, zIndex: 9999 },
className: "bg-[#2a2a2a] border border-zinc-600 rounded shadow-xl py-1 min-w-[140px]"
}, ...items);
};
const renderBarLabels = () => {
const labels = [];
const barsCount = Math.ceil(totalBeats / 4);
const extraBars = Math.max(0, Math.ceil((gridViewWidth - drawWidth) / (4 * pixelsPerBeat)) + 2);
const barsCount = Math.ceil(totalBeats / 4) + extraBars;
for (let bar = 0; bar < barsCount; bar++) {
const x = bar * 4 * pixelsPerBeat;
labels.push(
@@ -5222,6 +5410,19 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
onClick: () => setCcMode(mode),
className: `px-2.5 py-1 rounded capitalize ${ccMode === mode ? 'bg-purple-900/60 text-purple-300 font-bold border border-purple-700' : 'text-zinc-400 hover:text-zinc-200'}`
}, mode)))), /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-1 flex-1 max-w-[300px] ml-2"
}, /*#__PURE__*/React.createElement("input", {
type: "text",
value: aiPrompt,
onChange: e => setAiPrompt(e.target.value),
onKeyDown: e => { if (e.key === 'Enter') handleAIPrompt(); },
placeholder: "AI: tạo 8 bars MIDI...",
className: "flex-1 bg-zinc-900 border border-zinc-700 text-zinc-200 text-[10px] rounded px-2 py-1 outline-none focus:border-amber-500 min-w-0"
}), /*#__PURE__*/React.createElement("button", {
onClick: handleAIPrompt,
disabled: aiLoading,
className: "px-2 py-1 text-[10px] bg-purple-700 hover:bg-purple-600 disabled:bg-zinc-700 text-white rounded flex items-center gap-1 transition"
}, aiLoading ? "..." : "AI")), /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-1"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
@@ -5253,7 +5454,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
}
}, /*#__PURE__*/React.createElement("div", {
style: {
width: `${drawWidth}px`,
width: `${cssWidth}px`,
height: '100%'
},
className: "relative h-full font-mono text-[9px] text-zinc-500 font-bold"
@@ -5272,9 +5473,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
onScroll: handleScroll,
className: "flex-1 overflow-auto bg-[#141414] min-w-0"
}, /*#__PURE__*/React.createElement("div", {
style: {
width: `${drawWidth}px`,
height: `${(128 - PITCH_START) * NoteHeight}px`
style: {
width: `${cssWidth}px`,
height: `${(128 - PITCH_START) * NoteHeight}px`
},
className: "relative"
}, /*#__PURE__*/React.createElement("canvas", {
@@ -5293,15 +5494,18 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
className: "flex-1 overflow-x-hidden min-w-0"
}, /*#__PURE__*/React.createElement("div", {
style: {
width: `${drawWidth}px`,
width: `${cssWidth}px`,
height: '100%'
},
className: "relative"
}, /*#__PURE__*/React.createElement("canvas", {
ref: ccCanvasRef,
onMouseDown: handleCCMouseDown,
onMouseMove: handleCCMouseMove,
onMouseUp: () => { ccDragRef.current = null; },
onMouseLeave: () => { ccDragRef.current = null; },
className: "absolute inset-0 cursor-ns-resize"
})))));
})))), scaleMenuPos && renderScaleContextMenu());
};
const serializeTracksList = (tracksList, secondsPerBar) => {
@@ -8303,6 +8507,7 @@ const App = () => {
// Computed Values
const maxDuration = useMemo(() => {
const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
let max = 10;
tracks.forEach(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
@@ -9958,12 +10163,25 @@ const App = () => {
}
showToast('Đã xóa track.', 'info');
};
// Shared auto-scroll: when mouse near right edge, scroll container right
const autoScrollTimeline = (clientX) => {
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const wr = wrapper.getBoundingClientRect();
const margin = 60;
if (clientX > wr.right - margin) {
const speed = 15;
wrapper.scrollLeft += speed;
}
};
useEffect(() => {
const handleMouseMove = e => {
const drag = draggedClipRef.current;
if (!drag) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
autoScrollTimeline(e.clientX);
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
@@ -10114,6 +10332,7 @@ const App = () => {
if (!drag) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
autoScrollTimeline(e.clientX);
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
@@ -10177,6 +10396,7 @@ const App = () => {
if (!resize) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
autoScrollTimeline(e.clientX);
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
@@ -14616,6 +14836,7 @@ const App = () => {
zoom: zoom,
timelineWidth: timelineWidth,
viewportWidth: viewportWidth,
scrollLeft: scrollLeft,
onSelectRange: handleSelectRange,
onPlayheadSet: handlePlayheadSet,
isSelected: isSelected,
File diff suppressed because one or more lines are too long