Files
KGStudio/src/components/piano-roll/PianoRollToolbar.tsx
T
2026-07-01 22:31:35 -07:00

460 lines
17 KiB
TypeScript

import React from 'react';
import { FaMousePointer, FaPencilAlt } from 'react-icons/fa';
import { TbArrowBarToUp } from 'react-icons/tb';
import { ColorPalettePopup, KGDropdown } from '../common';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGCore } from '../../core/KGCore';
import {
getTranslatedAutomationOptions,
type PianoRollAutomationType,
} from './pianoRollAutomation';
import { useI18n } from '../../i18n/useI18n';
interface PianoRollToolbarProps {
showAudioSpectrogramToggle?: boolean;
audioSpectrogramEnabled?: boolean;
onAudioSpectrogramToggle?: () => void;
sheetMusicToggleDisabled?: boolean;
sheetMusicViewEnabled?: boolean;
onSheetMusicViewToggle?: () => void;
sheetMusicTrackScopeEnabled?: boolean;
onSheetMusicTrackScopeToggle?: () => void;
sheetQuantization?: string;
onSheetQuantizationChange?: (value: string) => void;
sheetQuantizationOptions?: string[];
activeTool: 'pointer' | 'pencil';
onToolSelect: (tool: 'pointer' | 'pencil') => void;
quantPosition: string;
quantLength: string;
onQuantSelect: (type: 'position' | 'length', value: string) => void;
snapping: string;
onSnappingSelect: (value: string) => void;
selectedMode: string;
onModeChange: (value: string) => void;
chordGuide: 'N' | 'T' | 'S' | 'D';
onChordGuideChange: (value: 'N' | 'T' | 'S' | 'D') => void;
blinkButton?: string | null;
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
thresholdDb?: number;
onThresholdChange?: (db: number) => void;
power?: number;
onPowerChange?: (power: number) => void;
zoom: number;
onZoomChange: (value: number) => void;
showAutomationControls?: boolean;
automationEnabled?: boolean;
automationType?: PianoRollAutomationType;
onAutomationToggle?: () => void;
onAutomationTypeChange?: (value: PianoRollAutomationType) => void;
onDetectChords?: () => void | Promise<void>;
detectingChords?: boolean;
onDetectTempo?: () => void | Promise<void>;
detectingTempo?: boolean;
selectedRegionColor?: string;
onRegionColorSelect?: (color: string | null) => void;
}
const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
showAudioSpectrogramToggle = false,
audioSpectrogramEnabled = false,
onAudioSpectrogramToggle,
sheetMusicToggleDisabled = false,
sheetMusicViewEnabled = false,
onSheetMusicViewToggle,
sheetMusicTrackScopeEnabled = false,
onSheetMusicTrackScopeToggle,
sheetQuantization = '16,48',
onSheetQuantizationChange,
sheetQuantizationOptions = [],
activeTool,
onToolSelect,
quantPosition,
quantLength,
onQuantSelect,
snapping,
onSnappingSelect,
selectedMode,
onModeChange,
chordGuide,
onChordGuideChange,
blinkButton = null,
mode = 'midi-edit',
thresholdDb = -25,
onThresholdChange,
power = 0.5,
onPowerChange,
zoom,
onZoomChange,
showAutomationControls = false,
automationEnabled = false,
automationType = 'pitch-bend',
onAutomationToggle,
onAutomationTypeChange,
onDetectChords,
detectingChords = false,
onDetectTempo,
detectingTempo = false,
selectedRegionColor,
onRegionColorSelect,
}) => {
const { t } = useI18n();
const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled;
const showAudioOnlyControls = mode === 'audio-waveform' && !sheetMusicViewEnabled;
const showSpectrogramOnlyControls = mode === 'spectrogram' && !sheetMusicViewEnabled;
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
const showSpecMenu = !sheetMusicViewEnabled && (!!onDetectChords || !!onDetectTempo);
const automationOptions = React.useMemo(() => getTranslatedAutomationOptions(t), [t]);
const snapOptions = React.useMemo(
() => KGPianoRollState.SNAP_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
[t],
);
const quantPositionOptions = React.useMemo(
() => KGPianoRollState.QUANT_POS_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
[t],
);
const quantLengthOptions = React.useMemo(
() => KGPianoRollState.QUANT_LEN_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
[t],
);
const POWER_OPTIONS = [
{ label: t('pianoRoll.power.linear'), value: '1.0' },
{ label: t('pianoRoll.power.sqrtDefault'), value: '0.5' },
{ label: t('pianoRoll.power.mild'), value: '0.4' },
{ label: t('pianoRoll.power.strong'), value: '0.3' },
];
const modeOptions = React.useMemo(
() => Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => {
const translationKey = `pianoRoll.modeOption.${id}`;
const translatedLabel = t(translationKey);
return {
label: translatedLabel === translationKey ? data.name : translatedLabel,
value: id,
};
}),
[t],
);
const CHORD_GUIDE_BUTTONS: Array<{ label: string; value: 'N' | 'T' | 'S' | 'D'; ariaLabel: string }> = [
{ label: '⊘', value: 'N', ariaLabel: t('pianoRoll.chordGuide.off') },
{ label: 'T', value: 'T', ariaLabel: t('pianoRoll.chordGuide.tonic') },
{ label: 'S', value: 'S', ariaLabel: t('pianoRoll.chordGuide.subdominant') },
{ label: 'D', value: 'D', ariaLabel: t('pianoRoll.chordGuide.dominant') },
];
const [showZoomSlider, setShowZoomSlider] = React.useState(false);
const zoomSliderRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!showZoomSlider) return;
const handleClickOutside = (e: MouseEvent) => {
if (zoomSliderRef.current && !zoomSliderRef.current.contains(e.target as Node)) {
setShowZoomSlider(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showZoomSlider]);
const [showMoreMenu, setShowMoreMenu] = React.useState(false);
const [showRegionColorPalette, setShowRegionColorPalette] = React.useState(false);
const specMenuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!showMoreMenu) return;
const handleClickOutside = (e: MouseEvent) => {
if (specMenuRef.current && !specMenuRef.current.contains(e.target as Node)) {
setShowMoreMenu(false);
setShowRegionColorPalette(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showMoreMenu]);
const spectrogramToggleButton = showAudioSpectrogramToggle ? (
<button
className={`tool-button icon-only sheet-mode-toggle ${audioSpectrogramEnabled ? 'active' : ''}`}
onClick={() => onAudioSpectrogramToggle?.()}
title={t('pianoRoll.spectrogramView')}
aria-label={t('pianoRoll.spectrogramView')}
>
<svg className="spectrogram-view-icon" width="12" height="12" viewBox="0 0 10 10" fill="currentColor">
<rect x="3" y="0.5" width="6.5" height="2.5" rx="0.4" />
<rect x="1.5" y="3.75" width="6.5" height="2.5" rx="0.4" />
<rect x="0" y="7" width="6.5" height="2.5" rx="0.4" />
</svg>
</button>
) : null;
const sheetMusicToggleButton = (
<button
className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicViewToggle?.()}
title={t('pianoRoll.sheetMusicView')}
aria-label={t('pianoRoll.sheetMusicView')}
disabled={sheetMusicToggleDisabled}
>
</button>
);
return (
<div className="piano-roll-toolbar">
{showMidiControls && (
<div className="toolbar-left">
{spectrogramToggleButton}
{sheetMusicToggleButton}
<button
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
onClick={() => onToolSelect('pointer')}
title={t('pianoRoll.pointerTool')}
>
<FaMousePointer className="piano-roll-tool-icon" />
</button>
<button
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
onClick={() => onToolSelect('pencil')}
title={t('pianoRoll.pencilTool')}
>
<FaPencilAlt className="piano-roll-tool-icon" />
</button>
{showAutomationControls && (
<div className="piano-roll-automation-toolbar-group">
<button
className={`tool-button automation-toggle-button ${automationEnabled ? 'active' : ''}`}
onClick={() => onAutomationToggle?.()}
title={t('pianoRoll.toggleAutomationLane')}
aria-label={t('pianoRoll.toggleAutomationLane')}
>
A
</button>
<KGDropdown
options={automationOptions}
value={automationType}
onChange={(value) => onAutomationTypeChange?.(value as PianoRollAutomationType)}
label={t('pianoRoll.automation')}
buttonClassName="automation-type-dropdown"
showValueAsLabel={true}
/>
</div>
)}
<KGDropdown
options={modeOptions}
value={selectedMode}
onChange={(value) => onModeChange(value)}
label={t('pianoRoll.mode')}
buttonClassName="mode-dropdown"
showValueAsLabel={true}
/>
<div className="piano-roll-chord-guide-toolbar-group" role="group" aria-label={t('pianoRoll.chordGuide')}>
{CHORD_GUIDE_BUTTONS.map((button, index) => (
<button
key={button.value}
type="button"
className={`tool-button automation-toggle-button chord-guide-toggle-button chord-guide-toggle-button-${index} ${chordGuide === button.value ? 'active' : ''}`}
onClick={() => onChordGuideChange(button.value)}
title={button.ariaLabel}
aria-label={button.ariaLabel}
aria-pressed={chordGuide === button.value}
>
{button.label}
</button>
))}
</div>
</div>
)}
{showAudioOnlyControls && (
<div className="toolbar-left">
{spectrogramToggleButton}
</div>
)}
{showSpectrogramOnlyControls && (
<div className="toolbar-left">
{spectrogramToggleButton}
</div>
)}
{sheetMusicViewEnabled && (
<div className="toolbar-left">
{spectrogramToggleButton}
{sheetMusicToggleButton}
{mode !== 'spectrogram' && (
<button
className={`tool-button icon-only sheet-track-scope-toggle ${sheetMusicTrackScopeEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicTrackScopeToggle?.()}
title={sheetMusicTrackScopeEnabled ? t('pianoRoll.showActiveRegionOnly') : t('pianoRoll.showEntireTrack')}
aria-label={sheetMusicTrackScopeEnabled ? t('pianoRoll.showActiveRegionOnly') : t('pianoRoll.showEntireTrack')}
>
<TbArrowBarToUp className="sheet-track-scope-icon" strokeWidth={2.5} />
</button>
)}
</div>
)}
<div className="toolbar-right">
{sheetMusicViewEnabled && (
<KGDropdown
options={sheetQuantizationOptions}
value={sheetQuantization}
onChange={(value) => onSheetQuantizationChange?.(value)}
label={t('pianoRoll.sheetQuantization')}
buttonClassName="sheet-quantization"
showValueAsLabel={true}
/>
)}
{showMidiControls && (
<>
<KGDropdown
options={snapOptions}
value={snapping}
onChange={(value) => onSnappingSelect(value)}
label={t('pianoRoll.snap')}
buttonClassName="snapping"
showValueAsLabel={true}
/>
<KGDropdown
options={quantPositionOptions}
value={quantPosition}
onChange={(value) => onQuantSelect('position', value)}
label={t('pianoRoll.quantizePositionCompact')}
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`}
/>
<KGDropdown
options={quantLengthOptions}
value={quantLength}
onChange={(value) => onQuantSelect('length', value)}
label={t('pianoRoll.quantizeLengthCompact')}
buttonClassName={`quant-length ${blinkButton === 'quant-length' ? 'button-blink' : ''}`}
/>
</>
)}
{showSpecControls && (
<div className="spectrogram-toolbar-controls">
<span className="spectrogram-control-label">{t('pianoRoll.floor')}</span>
<input
type="range"
className="spectrogram-threshold-slider"
min={-50}
max={-5}
step={1}
value={thresholdDb}
onChange={e => onThresholdChange?.(parseInt(e.target.value))}
title={`Noise floor: ${thresholdDb} dB`}
/>
<span className="spectrogram-threshold-value">{thresholdDb} dB</span>
<KGDropdown
options={POWER_OPTIONS}
value={power.toString()}
onChange={v => onPowerChange?.(parseFloat(v))}
label={t('pianoRoll.curve')}
buttonClassName="curve-dropdown"
showValueAsLabel={true}
/>
</div>
)}
{!sheetMusicViewEnabled && (
<div className="quant-dropdown-container" ref={zoomSliderRef}>
<button
className="quant-button"
onClick={() => setShowZoomSlider(!showZoomSlider)}
title={t('pianoRoll.zoom')}
aria-label={t('pianoRoll.zoom')}
>
{zoom}x
</button>
{showZoomSlider && (
<div className="piano-roll-zoom-popup">
<input
type="range"
min="1"
max="8"
step="1"
value={zoom}
onChange={(e) => onZoomChange(parseInt(e.target.value))}
/>
<span className="piano-roll-zoom-value">{zoom}x</span>
</div>
)}
</div>
)}
{showSpecMenu && (
<div className="quant-dropdown-container" ref={specMenuRef}>
<button
className="quant-button"
onClick={() => setShowMoreMenu(!showMoreMenu)}
title={t('pianoRoll.moreOptions')}
aria-label={t('pianoRoll.moreOptions')}
>
...
</button>
{showMoreMenu && (
<div className="quant-dropdown piano-roll-more-menu" style={{ right: 0, left: 'auto', width: 'auto', whiteSpace: 'nowrap' }}>
{onRegionColorSelect && (
<div className="piano-roll-menu-item-wrapper">
<div
className="quant-option"
onClick={() => setShowRegionColorPalette(open => !open)}
>
{t('pianoRoll.regionColor')}
</div>
{showRegionColorPalette && (
<div className="piano-roll-region-color-popup">
<ColorPalettePopup
selectedColor={selectedRegionColor}
onSelect={(color) => {
onRegionColorSelect(color);
setShowRegionColorPalette(false);
setShowMoreMenu(false);
}}
/>
</div>
)}
</div>
)}
{onDetectChords && (
<div
className={`quant-option${detectingChords ? ' disabled' : ''}`}
onClick={() => {
if (detectingChords) {
return;
}
setShowRegionColorPalette(false);
setShowMoreMenu(false);
void onDetectChords();
}}
aria-disabled={detectingChords}
>
{detectingChords ? t('pianoRoll.detectingChords') : t('pianoRoll.detectChords')}
</div>
)}
{onDetectTempo && (
<div
className={`quant-option${detectingTempo ? ' disabled' : ''}`}
onClick={() => {
if (detectingTempo) {
return;
}
setShowRegionColorPalette(false);
setShowMoreMenu(false);
void onDetectTempo();
}}
aria-disabled={detectingTempo}
>
{detectingTempo ? t('pianoRoll.detectingTempo') : t('pianoRoll.detectTempo')}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
};
export default PianoRollToolbar;