460 lines
17 KiB
TypeScript
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;
|