feat(piano-roll): context menu flip, track column, session duration
- Context menu flips upward near viewport bottom edge - Track column (120px) left of keybed: checkboxes per track to filter ghost notes; active track highlighted - Session mode: canvas extends to full session duration - Filtered ghost layers via ghostTrackFilter state (Set of track IDs) - Fixed bar label seek in session mode (was using wrong offset)
This commit is contained in:
+82
-14
@@ -4595,7 +4595,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
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 totalBeats = sessionSyncMode ? Math.max(rollBeats, sessionLengthBars * 4 + 16, 64) : Math.max(rollBeats, noteMaxBeat + 16, 64);
|
||||
const drawWidth = totalBeats * pixelsPerBeat;
|
||||
const [rollViewWidth, setRollViewWidth] = React.useState(800);
|
||||
const viewWidth = Math.max(drawWidth, rollViewWidth);
|
||||
@@ -4640,6 +4640,24 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
const sessionStartBar = sessionSyncMode && activeTargetItem
|
||||
? (activeTargetItem.startTime / secondsPerBar) : 0;
|
||||
|
||||
const [ghostTrackFilter, setGhostTrackFilter] = React.useState(null);
|
||||
|
||||
const sessionLengthBars = React.useMemo(function() {
|
||||
var maxSec = 0;
|
||||
(activeTracks || []).forEach(function(tr) {
|
||||
(tr.midiItems || []).forEach(function(m) {
|
||||
var end = (m.startTime || 0) + (m.duration || 4);
|
||||
if (end > maxSec) maxSec = end;
|
||||
});
|
||||
});
|
||||
return Math.ceil((maxSec || 4) / secondsPerBar);
|
||||
}, [activeTracks, secondsPerBar]);
|
||||
|
||||
const filteredGhostLayers = React.useMemo(function() {
|
||||
if (ghostTrackFilter === null || !ghostLayers.length) return ghostLayers;
|
||||
return ghostLayers.filter(function(l) { return ghostTrackFilter.has(l.track_id); });
|
||||
}, [ghostLayers, ghostTrackFilter]);
|
||||
|
||||
const handleSwitchMidiItem = React.useCallback(function(itemId) {
|
||||
if (itemId === st.target_id) return;
|
||||
if (st.isDirty) {
|
||||
@@ -4892,8 +4910,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
}
|
||||
|
||||
// Layer 2: Ghost Notes (background reference from other tracks)
|
||||
if (showGhostNotes && sessionSyncMode && ghostLayers.length > 0) {
|
||||
ghostLayers.forEach(function(layer) {
|
||||
if (showGhostNotes && sessionSyncMode && filteredGhostLayers.length > 0) {
|
||||
filteredGhostLayers.forEach(function(layer) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.25;
|
||||
ctx.fillStyle = layer.track_color || '#888';
|
||||
@@ -4985,7 +5003,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes, showGhostNotes, sessionSyncMode, ghostLayers]);
|
||||
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes, showGhostNotes, sessionSyncMode, filteredGhostLayers]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const canvas = ccCanvasRef.current;
|
||||
@@ -5774,24 +5792,28 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
setScaleMenuPos({ x: origin.x, y: origin.y, parentKey });
|
||||
});
|
||||
if (isOpen) {
|
||||
const subItems = [];
|
||||
const subs = [];
|
||||
Object.keys(val).forEach(subKey => {
|
||||
subItems.push(React.createElement("div", {
|
||||
subs.push(React.createElement("div", {
|
||||
key: subKey,
|
||||
onClick: () => { setSelectedScale(val[subKey]); closeMenu(); },
|
||||
className: "px-3 py-1.5 text-xs cursor-pointer hover:bg-amber-600 hover:text-white whitespace-nowrap " + (isSameScale(val[subKey], selectedScale) ? "bg-amber-800/40 text-amber-300" : "text-zinc-300")
|
||||
}, subKey));
|
||||
});
|
||||
var subH = subs.length * 30 + 16;
|
||||
var subTop = (origin.y + subH + 20 > window.innerHeight) ? (origin.y - subH) : origin.y;
|
||||
subMenu = React.createElement("div", {
|
||||
style: { position: "fixed", left: origin.x + 150, top: origin.y, zIndex: 10000 },
|
||||
style: { position: "fixed", left: origin.x + 150, top: subTop, zIndex: 10000 },
|
||||
className: "bg-[#2a2a2a] border border-zinc-600 rounded shadow-xl py-1 min-w-[120px]"
|
||||
}, ...subItems);
|
||||
}, ...subs);
|
||||
}
|
||||
}
|
||||
});
|
||||
var menuH = items.length * 30 + 16;
|
||||
var menuTop = (origin.y + menuH + 20 > window.innerHeight) ? Math.max(10, origin.y - menuH) : origin.y;
|
||||
return React.createElement(React.Fragment, null,
|
||||
React.createElement("div", {
|
||||
style: { position: "fixed", left: origin.x, top: origin.y, zIndex: 9999 },
|
||||
style: { position: "fixed", left: origin.x, top: menuTop, zIndex: 9999 },
|
||||
className: "bg-[#2a2a2a] border border-zinc-600 rounded shadow-xl py-1 min-w-[140px]"
|
||||
}, ...items),
|
||||
subMenu
|
||||
@@ -5816,7 +5838,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
className: "pl-1 border-l border-zinc-700 h-full select-none cursor-pointer hover:bg-zinc-800/30",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
const barTime = displayBar * 4 * beatSec;
|
||||
const barTime = bar * 4 * beatSec;
|
||||
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: barTime } : s));
|
||||
}
|
||||
}, `Bar ${displayBar}`)
|
||||
@@ -5972,10 +5994,14 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
className: "w-3 h-3"
|
||||
}), "Đóng"))),
|
||||
|
||||
/* 2. BAR RULER (ĐÃ SỬA LẠI ĐÓNG NGOẶC ĐÚNG TẠI ĐÂY) */
|
||||
/* 2. BAR RULER */
|
||||
React.createElement("div", {
|
||||
className: "h-6 bg-[#1a1a1f] border-b border-zinc-900 flex shrink-0"
|
||||
}, React.createElement("div", {
|
||||
className: "w-[120px] bg-[#1e1e22] border-r border-zinc-800 shrink-0 flex items-end"
|
||||
}, React.createElement("span", {
|
||||
className: "text-[8px] text-zinc-600 font-mono px-1.5 pb-0.5 uppercase tracking-wider"
|
||||
}, "Tracks")), React.createElement("div", {
|
||||
className: "w-[60px] bg-[#222222] border-r border-zinc-900 shrink-0"
|
||||
}), React.createElement("div", {
|
||||
ref: rulerScrollRef,
|
||||
@@ -6095,18 +6121,60 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
}
|
||||
}))))),
|
||||
|
||||
/* 3. MAIN PIANO ROLL GRID (KEYBOARD + CANVAS) */
|
||||
/* 3. MAIN PIANO ROLL GRID (KEYBOARD + TRACK COL + CANVAS) */
|
||||
React.createElement("div", {
|
||||
className: "flex-1 flex overflow-hidden min-h-0 relative"
|
||||
}, React.createElement("div", {
|
||||
ref: keybedRef,
|
||||
onScroll: handleKeybedScroll,
|
||||
className: "w-[60px] overflow-y-auto flex flex-col border-r border-zinc-900 shrink-0",
|
||||
className: "flex overflow-y-auto shrink-0 flex-row",
|
||||
style: {
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}
|
||||
}, renderKeybed()), React.createElement("div", {
|
||||
}, /* Track column */ React.createElement("div", {
|
||||
className: "w-[120px] bg-[#16161a] border-r border-zinc-800 shrink-0 flex flex-col",
|
||||
style: { height: KeybedPixelHeight + 'px', overflow: 'hidden' }
|
||||
}, (allMidiItems.length > 0 ? function() {
|
||||
var seenTracks = {};
|
||||
var els = [];
|
||||
allMidiItems.forEach(function(m) {
|
||||
if (seenTracks[m._trackId]) return;
|
||||
seenTracks[m._trackId] = true;
|
||||
var track = (activeTracks || []).find(function(t) { return t.id === m._trackId; });
|
||||
var isActive = m._trackId === st.trackId && m.id === st.target_id;
|
||||
var ghostEnabled = ghostTrackFilter === null || ghostTrackFilter.has(m._trackId);
|
||||
var hasGhost = ghostLayers.some(function(l) { return l.track_id === m._trackId; });
|
||||
els.push(React.createElement("div", {
|
||||
key: m._trackId,
|
||||
className: "flex items-center gap-1 px-1 h-[18px] border-b border-zinc-900 " + (isActive ? 'bg-yellow-900/20' : '')
|
||||
}, React.createElement("input", {
|
||||
type: "checkbox",
|
||||
checked: ghostEnabled,
|
||||
disabled: !hasGhost || isActive,
|
||||
onChange: function() {
|
||||
if (isActive) return;
|
||||
setGhostTrackFilter(function(prev) {
|
||||
var s = prev === null ? new Set() : new Set(prev);
|
||||
if (ghostEnabled) s.delete(m._trackId);
|
||||
else s.add(m._trackId);
|
||||
return s.size === 0 ? null : s;
|
||||
});
|
||||
},
|
||||
className: "w-2.5 h-2.5 accent-purple-500 cursor-pointer shrink-0"
|
||||
}), React.createElement("span", {
|
||||
className: "text-[9px] font-mono truncate " + (isActive ? 'text-yellow-400 font-bold' : hasGhost ? (ghostEnabled ? 'text-zinc-300' : 'text-zinc-600') : 'text-zinc-700'),
|
||||
title: track ? track.name : m._trackName
|
||||
}, track ? track.name : m._trackName)));
|
||||
});
|
||||
return els;
|
||||
}() : null)), React.createElement("div", {
|
||||
className: "w-[60px] flex flex-col border-r border-zinc-900 shrink-0",
|
||||
style: {
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}
|
||||
}, renderKeybed())), React.createElement("div", {
|
||||
ref: gridScrollRef,
|
||||
onScroll: handleScroll,
|
||||
className: "flex-1 overflow-auto bg-[#141414] min-w-0"
|
||||
|
||||
Reference in New Issue
Block a user