fix: piano roll zoom/scroll + AI copilot + SF cache + auto-scroll drag
This commit is contained in:
+289
-68
@@ -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: fast‑forward 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,
|
||||
|
||||
Reference in New Issue
Block a user