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:
2026-07-27 17:32:06 +07:00
parent d21091b69b
commit fb0cd248ad
2 changed files with 86 additions and 19 deletions
+82 -14
View File
@@ -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"