feat: brush-pen scale snap + scale sub-menu hover + keybed swipe

- Brush (pen tool only) draws notes snapped to selected scale
- Scale sub-menus open on hover instead of click
- Keybed: swipe/drag across keys plays notes (mouseenter+mousedown)
- Global mouseup resets keybed drag state
This commit is contained in:
2026-07-25 12:01:42 +07:00
parent 51787e055e
commit 225c44d98a
2 changed files with 27 additions and 7 deletions
+24 -4
View File
@@ -4554,7 +4554,13 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const ccWrapperRef = React.useRef(null);
const gridScrollRef = React.useRef(null);
const keybedRef = React.useRef(null);
const keybedMouseDownRef = React.useRef(false);
const rulerScrollRef = React.useRef(null);
React.useEffect(() => {
const up = () => { keybedMouseDownRef.current = false; };
window.addEventListener('mouseup', up);
return () => window.removeEventListener('mouseup', up);
}, []);
const NoteHeight = 18;
const PITCH_START = 0; // C0 (render all 128 keys)
@@ -5043,8 +5049,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
startOffsetPitch: pitch - clickedNote.pitch,
selectedNotesOffset: selectedNotesOffset
});
} else {
// Click on empty space: DRAW a new note (brush mode with visitedPitches)
} else if (activeRollTool === 'pen') {
// Click on empty space with pen tool: DRAW a new note (brush mode with visitedPitches)
pushToUndo(notes);
const start = getSnapBeat(beat, snapVal);
const initialDur = getSnapDuration(snapVal);
@@ -5340,6 +5346,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
className: `w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800' : 'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,
onMouseDown: (e) => {
e.stopPropagation();
keybedMouseDownRef.current = true;
try {
if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 500, undefined, st.instrumentProgram, null);
@@ -5348,6 +5355,16 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
console.error('playNote error:', err);
}
},
onMouseEnter: (e) => {
if (keybedMouseDownRef.current) {
try {
if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 200, undefined, st.instrumentProgram, null);
}
} catch (err) { console.error('playNote error:', err); }
}
},
onMouseUp: () => { keybedMouseDownRef.current = false; },
onContextMenu: (e) => {
e.preventDefault();
e.stopPropagation();
@@ -5498,11 +5515,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
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 pushItem = (label, onClick, indent, onHover) => {
const isActive = onClick._scale && isSameScale(onClick._scale, selectedScale);
items.push(React.createElement("div", {
key: label,
onClick: () => { onClick(); closeMenu(); },
onMouseEnter: onHover || undefined,
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));
};
@@ -5514,7 +5532,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
} else {
const parentKey = key;
const isOpen = scaleMenuPos && scaleMenuPos.parentKey === parentKey;
pushItem(key + " ▸", () => { setScaleMenuPos({ x: scaleMenuPos.x + 120, y: scaleMenuPos.y, parentKey }); }, false);
pushItem(key + " ▸", () => setSelectedScale(null), false, () => {
setScaleMenuPos({ x: scaleMenuPos.x + 120, y: scaleMenuPos.y, parentKey });
});
if (isOpen) {
Object.keys(val).forEach(subKey => {
pushItem(subKey, () => setSelectedScale(val[subKey]), true);
File diff suppressed because one or more lines are too long