323 lines
9.2 KiB
TypeScript
323 lines
9.2 KiB
TypeScript
import React from 'react';
|
|
import './ChordPickerPopup.css';
|
|
import KGDropdown from './common/KGDropdown';
|
|
import {
|
|
buildChordSymbol,
|
|
CHORD_EXTENSION_OPTIONS,
|
|
formatChordSymbolForDisplay,
|
|
type ChordDescriptor,
|
|
type ChordExtension,
|
|
type ChordQuality,
|
|
parseChordSymbol,
|
|
} from '../util/chordUtil';
|
|
|
|
interface ChordPickerPopupProps {
|
|
value: string;
|
|
onChange: (symbol: string) => void;
|
|
onTabNavigate?: (direction: 'forward' | 'backward') => void;
|
|
}
|
|
|
|
const ROOT_OPTIONS = ['C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'F', 'F#', 'Gb', 'G', 'G#', 'Ab', 'A', 'A#', 'Bb', 'B'] as const;
|
|
|
|
const QUALITY_LABELS: Record<ChordQuality, string> = {
|
|
maj: 'Maj',
|
|
min: 'Min',
|
|
sus2: 'Sus2',
|
|
sus4: 'Sus4',
|
|
power: '5',
|
|
aug: 'Aug',
|
|
dim: 'Dim',
|
|
};
|
|
|
|
const EXTENSION_LABELS: Record<ChordExtension, string> = {
|
|
b5: 'b5',
|
|
'#5': '#5',
|
|
'6': '6',
|
|
'7': '7',
|
|
dim7: 'dim7',
|
|
maj7: 'maj7',
|
|
b9: 'b9',
|
|
'9': '9',
|
|
'#9': '#9',
|
|
'11': '11',
|
|
'#11': '#11',
|
|
b13: 'b13',
|
|
'13': '13',
|
|
};
|
|
|
|
const QUALITY_ROWS: ChordQuality[][] = [
|
|
['maj', 'min', 'sus2', 'sus4'],
|
|
['power', 'aug', 'dim'],
|
|
];
|
|
|
|
const EXTENSION_ROWS: ChordExtension[][] = [
|
|
['b5', '#5', '6', '7'],
|
|
['dim7', 'maj7', 'b9', '9'],
|
|
['#9', '11', '#11', 'b13'],
|
|
['13'],
|
|
];
|
|
|
|
function createFallbackDescriptor(value: string): ChordDescriptor {
|
|
return {
|
|
root: 'C',
|
|
quality: 'maj',
|
|
extensions: [],
|
|
symbol: value || 'C',
|
|
};
|
|
}
|
|
|
|
function normalizeDescriptor(descriptor: ChordDescriptor): ChordDescriptor {
|
|
const quality = descriptor.quality;
|
|
let extensions = [...descriptor.extensions];
|
|
|
|
const dedupe = (nextExtensions: ChordExtension[]) => Array.from(new Set(nextExtensions));
|
|
const removeExtensions = (targets: ChordExtension[]) => {
|
|
extensions = extensions.filter(extension => !targets.includes(extension));
|
|
};
|
|
|
|
if (quality === 'aug' && !extensions.includes('#5')) {
|
|
extensions.push('#5');
|
|
}
|
|
if (quality === 'dim' && !extensions.includes('b5')) {
|
|
extensions.push('b5');
|
|
}
|
|
if (quality === 'aug') {
|
|
removeExtensions(['b5']);
|
|
}
|
|
if (quality === 'dim') {
|
|
removeExtensions(['#5']);
|
|
}
|
|
if (extensions.includes('7')) {
|
|
removeExtensions(['dim7', 'maj7', '6']);
|
|
}
|
|
if (extensions.includes('dim7')) {
|
|
removeExtensions(['7', 'maj7', '6']);
|
|
}
|
|
if (extensions.includes('maj7')) {
|
|
removeExtensions(['7', 'dim7', '6']);
|
|
}
|
|
if (extensions.includes('6')) {
|
|
removeExtensions(['7', 'dim7', 'maj7', '13', 'b13']);
|
|
}
|
|
if (extensions.includes('13') || extensions.includes('b13')) {
|
|
removeExtensions(['6']);
|
|
}
|
|
|
|
if (extensions.includes('b9')) {
|
|
removeExtensions(['9', '#9']);
|
|
} else if (extensions.includes('9')) {
|
|
removeExtensions(['b9', '#9']);
|
|
} else if (extensions.includes('#9')) {
|
|
removeExtensions(['b9', '9']);
|
|
}
|
|
|
|
if (extensions.includes('11')) {
|
|
removeExtensions(['#11']);
|
|
} else if (extensions.includes('#11')) {
|
|
removeExtensions(['11']);
|
|
}
|
|
|
|
if (extensions.includes('b13')) {
|
|
removeExtensions(['13']);
|
|
} else if (extensions.includes('13')) {
|
|
removeExtensions(['b13']);
|
|
}
|
|
|
|
return {
|
|
...descriptor,
|
|
quality,
|
|
extensions: CHORD_EXTENSION_OPTIONS.filter(extension => dedupe(extensions).includes(extension)),
|
|
};
|
|
}
|
|
|
|
const ChordPickerPopup: React.FC<ChordPickerPopupProps> = ({ value, onChange, onTabNavigate }) => {
|
|
const [descriptor, setDescriptor] = React.useState<ChordDescriptor>(() => parseChordSymbol(value) ?? createFallbackDescriptor(value));
|
|
const [inputText, setInputText] = React.useState(value);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [showRootDropdown, setShowRootDropdown] = React.useState(false);
|
|
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
const parsed = parseChordSymbol(value);
|
|
setDescriptor(parsed ?? createFallbackDescriptor(value));
|
|
setInputText(value);
|
|
setError(null);
|
|
setShowRootDropdown(false);
|
|
}, [value]);
|
|
|
|
React.useEffect(() => {
|
|
if (!inputRef.current) {
|
|
return;
|
|
}
|
|
|
|
inputRef.current.focus();
|
|
inputRef.current.select();
|
|
}, [value]);
|
|
|
|
const applyDescriptor = React.useCallback((nextDescriptor: ChordDescriptor) => {
|
|
const normalized = normalizeDescriptor(nextDescriptor);
|
|
const nextSymbol = buildChordSymbol(normalized);
|
|
if (!nextSymbol) {
|
|
setError('Unsupported chord combination');
|
|
return;
|
|
}
|
|
|
|
const parsed = parseChordSymbol(nextSymbol);
|
|
if (!parsed) {
|
|
setError('Unsupported chord combination');
|
|
return;
|
|
}
|
|
|
|
setDescriptor(parsed);
|
|
setInputText(parsed.symbol);
|
|
setError(null);
|
|
onChange(parsed.symbol);
|
|
}, [onChange]);
|
|
|
|
const handleQualityChange = React.useCallback((quality: ChordQuality) => {
|
|
applyDescriptor({
|
|
...descriptor,
|
|
quality,
|
|
});
|
|
}, [applyDescriptor, descriptor]);
|
|
|
|
const handleExtensionToggle = React.useCallback((extension: ChordExtension) => {
|
|
const isSelected = descriptor.extensions.includes(extension);
|
|
const nextExtensions = isSelected
|
|
? descriptor.extensions.filter(candidate => candidate !== extension)
|
|
: [...descriptor.extensions, extension];
|
|
|
|
let nextQuality = descriptor.quality;
|
|
if (!isSelected && extension === 'b5' && nextQuality === 'aug') {
|
|
nextQuality = 'maj';
|
|
}
|
|
if (!isSelected && extension === '#5' && nextQuality === 'dim') {
|
|
nextQuality = 'maj';
|
|
}
|
|
|
|
applyDescriptor({
|
|
...descriptor,
|
|
quality: nextQuality,
|
|
extensions: nextExtensions,
|
|
});
|
|
}, [applyDescriptor, descriptor]);
|
|
|
|
const handleSubmit = React.useCallback(() => {
|
|
const parsed = parseChordSymbol(inputText);
|
|
if (!parsed) {
|
|
setError('Unable to parse chord');
|
|
return false;
|
|
}
|
|
|
|
setDescriptor(parsed);
|
|
setInputText(parsed.symbol);
|
|
setError(null);
|
|
onChange(parsed.symbol);
|
|
return true;
|
|
}, [inputText, onChange]);
|
|
|
|
return (
|
|
<div
|
|
className="chord-picker-popup"
|
|
onKeyDownCapture={(event) => {
|
|
if (event.key === 'Tab' && onTabNavigate) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const isValid = handleSubmit();
|
|
if (!isValid) {
|
|
return;
|
|
}
|
|
|
|
onTabNavigate(event.shiftKey ? 'backward' : 'forward');
|
|
}
|
|
}}
|
|
>
|
|
<div className="chord-picker-header">
|
|
<label className="chord-picker-input-shell">
|
|
<span className="chord-picker-input-label">Chord</span>
|
|
<input
|
|
ref={inputRef}
|
|
className={`chord-picker-input${error ? ' invalid' : ''}`}
|
|
value={inputText}
|
|
onChange={(event) => {
|
|
setInputText(event.target.value);
|
|
if (error) {
|
|
setError(null);
|
|
}
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => event.stopPropagation()}
|
|
placeholder="Type a chord"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="chord-picker-section">
|
|
<div className="chord-picker-section-header">
|
|
<span>Root Note</span>
|
|
<KGDropdown
|
|
options={[...ROOT_OPTIONS]}
|
|
value={descriptor.root}
|
|
onChange={(nextRoot) => applyDescriptor({ ...descriptor, root: nextRoot })}
|
|
label="Root Note"
|
|
showValueAsLabel={true}
|
|
isOpen={showRootDropdown}
|
|
onToggle={setShowRootDropdown}
|
|
buttonClassName="chord-picker-root-button"
|
|
className="chord-picker-root-dropdown"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="chord-picker-section chord-picker-grid">
|
|
{QUALITY_ROWS.map((row, rowIndex) => (
|
|
<div className="chord-picker-button-row" key={`quality-row-${rowIndex}`}>
|
|
{row.map((quality) => (
|
|
<button
|
|
key={quality}
|
|
type="button"
|
|
aria-label={QUALITY_LABELS[quality]}
|
|
className={`chord-picker-button quality${descriptor.quality === quality ? ' selected' : ''}`}
|
|
onClick={() => handleQualityChange(quality)}
|
|
>
|
|
{QUALITY_LABELS[quality]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="chord-picker-section chord-picker-grid">
|
|
{EXTENSION_ROWS.map((row, rowIndex) => (
|
|
<div className="chord-picker-button-row" key={`extension-row-${rowIndex}`}>
|
|
{row.map((extension) => (
|
|
<button
|
|
key={extension}
|
|
type="button"
|
|
aria-label={EXTENSION_LABELS[extension]}
|
|
className={`chord-picker-button extension${descriptor.extensions.includes(extension) ? ' selected' : ''}`}
|
|
onClick={() => handleExtensionToggle(extension)}
|
|
>
|
|
{EXTENSION_LABELS[extension]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="chord-picker-footer">
|
|
<span className="chord-picker-preview">{formatChordSymbolForDisplay(descriptor.symbol || value)}</span>
|
|
{error && <span className="chord-picker-error">{error}</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ChordPickerPopup;
|