feat: implement global chord track
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
.chord-picker-popup {
|
||||
min-width: min(92vw, 270px);
|
||||
padding: 16px 16px 14px;
|
||||
color: #e0e0e0;
|
||||
font-family: inherit;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.chord-picker-header,
|
||||
.chord-picker-section,
|
||||
.chord-picker-footer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chord-picker-section + .chord-picker-section {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(68, 79, 94, 0.16);
|
||||
}
|
||||
|
||||
.chord-picker-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chord-picker-input-shell {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chord-picker-input-label,
|
||||
.chord-picker-section-header span {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.chord-picker-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background-color: #4a4a4a;
|
||||
border: 1px solid #5a5a5a;
|
||||
border-radius: 6px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
min-height: 36px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chord-picker-input.invalid {
|
||||
border-color: #d45a5a;
|
||||
}
|
||||
|
||||
.chord-picker-input:focus {
|
||||
border-color: #5a9fd4;
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.chord-picker-input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.chord-picker-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chord-picker-root-dropdown {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chord-picker-root-button.quant-button {
|
||||
margin-left: 0;
|
||||
min-width: 72px;
|
||||
height: 24px;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.chord-picker-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chord-picker-button-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 52px));
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chord-picker-button {
|
||||
--toolbar-button-bg: transparent;
|
||||
--toolbar-button-fg: #e0e0e0;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
background-color: var(--toolbar-button-bg);
|
||||
color: var(--toolbar-button-fg);
|
||||
min-height: 24px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.12s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.chord-picker-button:hover {
|
||||
--toolbar-button-bg: #3a3a3a;
|
||||
}
|
||||
|
||||
.chord-picker-button.selected {
|
||||
--toolbar-button-bg: #e0e0e0;
|
||||
--toolbar-button-fg: #2d2d2d;
|
||||
}
|
||||
|
||||
.chord-picker-button.selected:hover {
|
||||
--toolbar-button-bg: #f0f0f0;
|
||||
}
|
||||
|
||||
.chord-picker-button:active {
|
||||
--toolbar-button-bg: #5a5a5a;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.chord-picker-button.selected:active {
|
||||
--toolbar-button-bg: #d6d6d6;
|
||||
}
|
||||
|
||||
.chord-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.chord-picker-preview {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: #b8b8b8;
|
||||
}
|
||||
|
||||
.chord-picker-error {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #d45a5a;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.chord-picker-popup {
|
||||
min-width: min(94vw, 270px);
|
||||
padding: 14px 12px 12px;
|
||||
}
|
||||
|
||||
.chord-picker-button-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ChordPickerPopup from './ChordPickerPopup';
|
||||
|
||||
describe('ChordPickerPopup', () => {
|
||||
it('parses text input on enter and syncs the related buttons', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<ChordPickerPopup value="C" onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'Bm7b5' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('Bm7b5');
|
||||
expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected');
|
||||
expect(screen.getByRole('button', { name: 'b5' }).className).toContain('selected');
|
||||
expect(screen.getByRole('button', { name: '7' }).className).toContain('selected');
|
||||
});
|
||||
|
||||
it('shows an inline error for invalid text without mutating the region', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<ChordPickerPopup value="C" onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'invalid' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Unable to parse chord')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('intercepts tab and delegates popup bar navigation', () => {
|
||||
const onTabNavigate = vi.fn();
|
||||
|
||||
render(<ChordPickerPopup value="C" onChange={vi.fn()} onTabNavigate={onTabNavigate} />);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
|
||||
|
||||
expect(onTabNavigate).toHaveBeenCalledWith('forward');
|
||||
});
|
||||
|
||||
it('intercepts shift-tab and delegates backward popup bar navigation', () => {
|
||||
const onTabNavigate = vi.fn();
|
||||
|
||||
render(<ChordPickerPopup value="C" onChange={vi.fn()} onTabNavigate={onTabNavigate} />);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab', shiftKey: true });
|
||||
|
||||
expect(onTabNavigate).toHaveBeenCalledWith('backward');
|
||||
});
|
||||
|
||||
it('focuses the chord input when the popup opens', () => {
|
||||
render(<ChordPickerPopup value="C" onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('blocks tab navigation when the current input is invalid', () => {
|
||||
const onChange = vi.fn();
|
||||
const onTabNavigate = vi.fn();
|
||||
|
||||
render(<ChordPickerPopup value="C" onChange={onChange} onTabNavigate={onTabNavigate} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'invalid' } });
|
||||
fireEvent.keyDown(input, { key: 'Tab' });
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(onTabNavigate).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Unable to parse chord')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
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',
|
||||
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'],
|
||||
['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 {
|
||||
let 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(['maj7', '6']);
|
||||
}
|
||||
if (extensions.includes('maj7')) {
|
||||
removeExtensions(['7', '6']);
|
||||
}
|
||||
if (extensions.includes('6')) {
|
||||
removeExtensions(['7', '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;
|
||||
@@ -311,6 +311,10 @@
|
||||
z-index: 1004;
|
||||
}
|
||||
|
||||
.global-chord-lane.popup-open {
|
||||
z-index: 1004;
|
||||
}
|
||||
|
||||
.global-marker-lane.pencil-cursor {
|
||||
cursor: crosshair;
|
||||
}
|
||||
@@ -355,6 +359,12 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.global-chord-region {
|
||||
background: #dc8b3f;
|
||||
color: #2f2215;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.global-key-signature-region .floating-popup {
|
||||
min-width: 0;
|
||||
width: var(--track-grid-bar-width);
|
||||
@@ -385,6 +395,38 @@
|
||||
z-index: 1009;
|
||||
}
|
||||
|
||||
.global-chord-region .floating-popup {
|
||||
min-width: 0;
|
||||
width: var(--track-grid-bar-width);
|
||||
}
|
||||
|
||||
.global-chord-popup-anchor {
|
||||
min-width: 0;
|
||||
width: var(--track-grid-bar-width);
|
||||
position: absolute;
|
||||
left: calc((var(--track-grid-bar-width) - var(--track-grid-bar-width)) / 2);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1008;
|
||||
}
|
||||
|
||||
.global-chord-trigger {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-chord-popup-surface {
|
||||
--floating-popup-bg: #2d2d2d;
|
||||
--floating-popup-border: #555;
|
||||
z-index: 10007;
|
||||
}
|
||||
|
||||
.global-chord-popup-anchor .floating-popup-surface {
|
||||
z-index: 10009;
|
||||
}
|
||||
|
||||
.global-marker-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -4,8 +4,9 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import MainContent from './MainContent';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGChordRegion } from '../core/region/KGChordRegion';
|
||||
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||
import { createDefaultGlobalTracks } from '../core/global-track';
|
||||
import { createDefaultGlobalTracks, GlobalTrackType } from '../core/global-track';
|
||||
import { createMockMidiTrack } from '../test/utils/mock-data';
|
||||
|
||||
const executeCommandMock = vi.fn();
|
||||
@@ -138,9 +139,11 @@ vi.mock('./piano-roll/PianoRoll', () => ({
|
||||
|
||||
describe('MainContent', () => {
|
||||
beforeEach(() => {
|
||||
storeState.globalTracks = createDefaultGlobalTracks();
|
||||
storeState.selectedRegionIds = [];
|
||||
storeState.activeRegionId = null;
|
||||
storeState.showPianoRoll = false;
|
||||
storeState.playheadPosition = 0;
|
||||
storeState.timeSignature = { numerator: 4, denominator: 4 };
|
||||
storeState.clearAllSelections.mockClear();
|
||||
storeState.setSelectedTrack.mockClear();
|
||||
@@ -332,7 +335,8 @@ describe('MainContent', () => {
|
||||
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes the tempo global track add button through a command and keeps chord visual-only', () => {
|
||||
it('routes the tempo and chord global track add buttons through commands', () => {
|
||||
storeState.playheadPosition = 5;
|
||||
render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
@@ -344,7 +348,87 @@ describe('MainContent', () => {
|
||||
|
||||
expect(storeState.addTrack).not.toHaveBeenCalled();
|
||||
expect(storeState.addAudioTrack).not.toHaveBeenCalled();
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(2);
|
||||
expect((executeCommandMock.mock.calls[1][0] as { startBeat?: number }).startBeat).toBe(5);
|
||||
});
|
||||
|
||||
it('uses split-insert chord command when the playhead is inside an existing chord region', () => {
|
||||
const globalTracks = createDefaultGlobalTracks();
|
||||
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
|
||||
chordTrack?.addRegion(new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8));
|
||||
storeState.globalTracks = globalTracks;
|
||||
storeState.playheadPosition = 3;
|
||||
|
||||
render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chord global track item' }));
|
||||
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect((executeCommandMock.mock.calls[0][0] as { insertBeat?: number }).insertBeat).toBe(3);
|
||||
|
||||
storeState.globalTracks = createDefaultGlobalTracks();
|
||||
});
|
||||
|
||||
it('tabs the open chord popup to the next chord region before the next bar', () => {
|
||||
const globalTracks = createDefaultGlobalTracks();
|
||||
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
|
||||
chordTrack?.setRegions([
|
||||
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 3),
|
||||
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 2, 2),
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
fireEvent.doubleClick(screen.getByText('Am'));
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
|
||||
|
||||
expect(storeState.selectedRegionIds).toEqual(['chord-2']);
|
||||
|
||||
storeState.globalTracks = createDefaultGlobalTracks();
|
||||
});
|
||||
|
||||
it('tabs the open chord popup to insert at the next exact bar when no chord starts before it', () => {
|
||||
const globalTracks = createDefaultGlobalTracks();
|
||||
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
|
||||
chordTrack?.setRegions([
|
||||
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
fireEvent.doubleClick(screen.getByText('Am'));
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
|
||||
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect((executeCommandMock.mock.calls[0][0] as { insertBeat?: number }).insertBeat).toBe(4);
|
||||
|
||||
storeState.globalTracks = createDefaultGlobalTracks();
|
||||
});
|
||||
|
||||
it('shift-tabs the open chord popup to the previous existing chord without creating a new one', () => {
|
||||
const globalTracks = createDefaultGlobalTracks();
|
||||
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
|
||||
chordTrack?.setRegions([
|
||||
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 4),
|
||||
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 8, 4),
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
fireEvent.doubleClick(screen.getByText('G'));
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab', shiftKey: true });
|
||||
|
||||
expect(storeState.selectedRegionIds).toEqual(['chord-1']);
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
storeState.globalTracks = createDefaultGlobalTracks();
|
||||
});
|
||||
|
||||
it('routes the signature global track add button through a command', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { GlobalTrackType } from '../core/global-track';
|
||||
import { KGTrack } from '../core/track/KGTrack';
|
||||
import { KGRegion } from '../core/region/KGRegion';
|
||||
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||
import { KGChordRegion } from '../core/region/KGChordRegion';
|
||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
@@ -18,6 +19,7 @@ import TrackGridPanel from './track/TrackGridPanel';
|
||||
import GlobalKeySignatureLane from './global-track/GlobalKeySignatureLane';
|
||||
import GlobalTempoLane from './global-track/GlobalTempoLane';
|
||||
import GlobalMarkerLane from './global-track/GlobalMarkerLane';
|
||||
import GlobalChordLane from './global-track/GlobalChordLane';
|
||||
import PianoRoll from './piano-roll/PianoRoll';
|
||||
import { TrackCreateDialog } from './common';
|
||||
import type { RegionClickOptions, RegionUI } from './interfaces';
|
||||
@@ -27,6 +29,7 @@ import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||
import {
|
||||
ChangeLoopSettingsCommand,
|
||||
CreateChordRegionCommand,
|
||||
CreateGlobalMarkerRegionCommand,
|
||||
CreateKeySignatureRegionCommand,
|
||||
CreateTempoRegionCommand,
|
||||
@@ -36,10 +39,12 @@ import {
|
||||
DeleteTempoRegionCommand,
|
||||
DeleteMultipleGlobalRegionsCommand,
|
||||
DeleteTrackAutomationPointsCommand,
|
||||
InsertChordRegionAtBeatCommand,
|
||||
MoveGlobalRegionCommand,
|
||||
ResizeKeySignatureRegionCommand,
|
||||
ResizeTempoRegionCommand,
|
||||
ResizeGlobalRegionCommand,
|
||||
UpdateChordRegionCommand,
|
||||
UpdateKeySignatureRegionCommand,
|
||||
UpdateGlobalRegionTextCommand,
|
||||
UpdateTempoRegionCommand,
|
||||
@@ -130,6 +135,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState<string | null>(null);
|
||||
const [editingTempoRegionId, setEditingTempoRegionId] = useState<string | null>(null);
|
||||
const [editingTempoText, setEditingTempoText] = useState('');
|
||||
const [editingChordRegionId, setEditingChordRegionId] = useState<string | null>(null);
|
||||
|
||||
// Use the region operations hook
|
||||
const { deleteSelectedRegions } = useRegionOperations({
|
||||
@@ -186,6 +192,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const signatureRegions = signatureTrack ? getSortedKeySignatureRegions(signatureTrack, timeSignature.numerator) : [];
|
||||
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null;
|
||||
const tempoRegions = tempoTrack ? getSortedTempoRegions(tempoTrack, timeSignature.numerator) : [];
|
||||
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord) ?? null;
|
||||
const chordRegions = (chordTrack?.getRegions() ?? []).filter(
|
||||
(region): region is KGChordRegion => region instanceof KGChordRegion
|
||||
);
|
||||
|
||||
const findProjectRegionById = useCallback((regionId: string): KGRegion | null => {
|
||||
for (const track of tracks) {
|
||||
@@ -251,13 +261,16 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setEditingTempoRegionId(null);
|
||||
setEditingTempoText('');
|
||||
}
|
||||
if (editingChordRegionId && selectedGlobalRegionIds.includes(editingChordRegionId)) {
|
||||
setEditingChordRegionId(null);
|
||||
}
|
||||
refreshProjectState();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting global marker regions:', error);
|
||||
return false;
|
||||
}
|
||||
}, [bumpAudioWaveformRedrawVersion, editingGlobalRegionId, editingKeySignatureRegionId, editingTempoRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
||||
}, [bumpAudioWaveformRedrawVersion, editingChordRegionId, editingGlobalRegionId, editingKeySignatureRegionId, editingTempoRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
|
||||
|
||||
// Register the delete function with the global manager
|
||||
useEffect(() => {
|
||||
@@ -952,6 +965,15 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setEditingTempoText(region.getBpm().toString());
|
||||
}, [findProjectRegionById]);
|
||||
|
||||
const beginEditingChordRegion = useCallback((regionId: string) => {
|
||||
const region = findProjectRegionById(regionId);
|
||||
if (!(region instanceof KGChordRegion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingChordRegionId(regionId);
|
||||
}, [findProjectRegionById]);
|
||||
|
||||
const commitGlobalRegionEdit = useCallback((regionId: string) => {
|
||||
const region = findProjectRegionById(regionId);
|
||||
if (!(region instanceof KGMarkerRegion)) {
|
||||
@@ -1160,6 +1182,144 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}
|
||||
}, [bumpAudioWaveformRedrawVersion, refreshProjectState]);
|
||||
|
||||
const createChordAtBeat = useCallback((requestedStartBeat: number) => {
|
||||
const normalizedStartBeat = Math.max(0, Math.round(requestedStartBeat));
|
||||
const occupiedRegion = chordRegions.find(region => (
|
||||
normalizedStartBeat >= region.getStartFromBeat()
|
||||
&& normalizedStartBeat < region.getStartFromBeat() + region.getLength()
|
||||
));
|
||||
if (occupiedRegion) {
|
||||
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
|
||||
beginEditingChordRegion(occupiedRegion.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new CreateChordRegionCommand(normalizedStartBeat, timeSignature.numerator, 'C');
|
||||
KGCore.instance().executeCommand(command);
|
||||
refreshProjectState();
|
||||
|
||||
const createdRegion = command.getCreatedRegion();
|
||||
if (!createdRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
|
||||
setEditingChordRegionId(createdRegion.getId());
|
||||
} catch (error) {
|
||||
console.error('Error creating chord region:', error);
|
||||
}
|
||||
}, [beginEditingChordRegion, chordRegions, refreshProjectState, selectGlobalRegion, timeSignature.numerator]);
|
||||
|
||||
const createChordAtExactBeat = useCallback((requestedStartBeat: number) => {
|
||||
const normalizedStartBeat = Math.max(0, Math.round(requestedStartBeat));
|
||||
const occupiedRegion = chordRegions.find(region => (
|
||||
normalizedStartBeat > region.getStartFromBeat()
|
||||
&& normalizedStartBeat < region.getStartFromBeat() + region.getLength()
|
||||
));
|
||||
|
||||
try {
|
||||
const command = occupiedRegion
|
||||
? new InsertChordRegionAtBeatCommand(normalizedStartBeat, 'C')
|
||||
: new CreateChordRegionCommand(normalizedStartBeat, timeSignature.numerator, 'C');
|
||||
|
||||
KGCore.instance().executeCommand(command);
|
||||
refreshProjectState();
|
||||
|
||||
const createdRegion = command.getCreatedRegion();
|
||||
if (!createdRegion) {
|
||||
if (occupiedRegion) {
|
||||
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
|
||||
beginEditingChordRegion(occupiedRegion.getId());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
|
||||
setEditingChordRegionId(createdRegion.getId());
|
||||
return createdRegion;
|
||||
} catch (error) {
|
||||
console.error('Error creating chord region at exact beat:', error);
|
||||
if (occupiedRegion) {
|
||||
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
|
||||
beginEditingChordRegion(occupiedRegion.getId());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [beginEditingChordRegion, chordRegions, refreshProjectState, selectGlobalRegion, timeSignature.numerator]);
|
||||
|
||||
const createChordAtPlayheadBeat = useCallback(() => {
|
||||
createChordAtExactBeat(playheadPosition);
|
||||
}, [createChordAtExactBeat, playheadPosition]);
|
||||
|
||||
const navigateChordPopupByBar = useCallback((currentRegionId: string, direction: 'forward' | 'backward') => {
|
||||
const currentRegion = chordRegions.find(region => region.getId() === currentRegionId);
|
||||
if (!currentRegion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const currentStartBeat = currentRegion.getStartFromBeat();
|
||||
const currentBarBeat = Math.floor(currentStartBeat / beatsPerBar) * beatsPerBar;
|
||||
const targetBarBeat = direction === 'forward'
|
||||
? currentBarBeat + beatsPerBar
|
||||
: currentBarBeat - beatsPerBar;
|
||||
const songEndBeat = maxBars * beatsPerBar;
|
||||
if (targetBarBeat < 0 || targetBarBeat >= songEndBeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedRegions = [...chordRegions]
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
|
||||
const targetRegion = direction === 'forward'
|
||||
? sortedRegions.find(region => region.getStartFromBeat() > currentStartBeat && region.getStartFromBeat() <= targetBarBeat)
|
||||
: [...sortedRegions]
|
||||
.reverse()
|
||||
.find(region => region.getStartFromBeat() < currentStartBeat);
|
||||
|
||||
if (targetRegion) {
|
||||
selectGlobalRegion(targetRegion.getId(), { shiftKey: false });
|
||||
setEditingChordRegionId(targetRegion.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction === 'forward') {
|
||||
createChordAtExactBeat(targetBarBeat);
|
||||
}
|
||||
}, [chordRegions, createChordAtExactBeat, maxBars, selectGlobalRegion, timeSignature.numerator]);
|
||||
|
||||
const moveGlobalChordRegion = useCallback((regionId: string, startBeat: number) => {
|
||||
try {
|
||||
KGCore.instance().executeCommand(new MoveGlobalRegionCommand(regionId, Math.round(startBeat)));
|
||||
refreshProjectState();
|
||||
} catch (error) {
|
||||
console.error('Error moving chord region:', error);
|
||||
}
|
||||
}, [refreshProjectState]);
|
||||
|
||||
const resizeGlobalChordRegion = useCallback((regionId: string, edge: 'start' | 'end', beat: number) => {
|
||||
try {
|
||||
KGCore.instance().executeCommand(new ResizeGlobalRegionCommand(regionId, edge, Math.round(beat)));
|
||||
refreshProjectState();
|
||||
} catch (error) {
|
||||
console.error('Error resizing chord region:', error);
|
||||
}
|
||||
}, [refreshProjectState]);
|
||||
|
||||
const updateChordRegion = useCallback((regionId: string, symbol: string) => {
|
||||
const region = findProjectRegionById(regionId);
|
||||
if (!(region instanceof KGChordRegion) || region.getSymbol() === symbol) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
KGCore.instance().executeCommand(new UpdateChordRegionCommand(regionId, symbol));
|
||||
refreshProjectState();
|
||||
} catch (error) {
|
||||
console.error('Error updating chord region:', error);
|
||||
}
|
||||
}, [findProjectRegionById, refreshProjectState]);
|
||||
|
||||
/**
|
||||
* Add keyboard event listener for region deletion
|
||||
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
|
||||
@@ -1520,6 +1680,11 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
|
||||
if (track.id === 'signature') {
|
||||
createKeySignatureAtPlayheadBar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.id === 'chord') {
|
||||
createChordAtPlayheadBeat();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1590,6 +1755,24 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
onChangeKeySignature={updateKeySignatureRegion}
|
||||
onOpenPicker={beginEditingKeySignatureRegion}
|
||||
/>
|
||||
) : track.id === 'chord' ? (
|
||||
<GlobalChordLane
|
||||
key={track.id}
|
||||
chordRegions={chordRegions}
|
||||
maxBars={maxBars}
|
||||
barWidthMultiplier={barWidthMultiplier}
|
||||
timeSignature={timeSignature}
|
||||
selectedRegionIds={selectedRegionIds}
|
||||
popupRegionId={editingChordRegionId}
|
||||
onClosePopup={() => setEditingChordRegionId(null)}
|
||||
onSelectRegion={selectGlobalRegion}
|
||||
onCreateAtBeat={createChordAtBeat}
|
||||
onMoveRegion={moveGlobalChordRegion}
|
||||
onResizeRegion={resizeGlobalChordRegion}
|
||||
onChangeChord={updateChordRegion}
|
||||
onOpenPopup={beginEditingChordRegion}
|
||||
onTabNavigate={navigateChordPopupByBar}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={track.id}
|
||||
|
||||
@@ -26,4 +26,27 @@ describe('FloatingPopup', () => {
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops escape from bubbling to outer window listeners', () => {
|
||||
const onClose = vi.fn();
|
||||
const outerEscapeHandler = vi.fn();
|
||||
window.addEventListener('keydown', outerEscapeHandler);
|
||||
|
||||
render(
|
||||
<FloatingPopup
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
trigger={<button type="button">Toggle</button>}
|
||||
>
|
||||
<div>Popup body</div>
|
||||
</FloatingPopup>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(outerEscapeHandler).not.toHaveBeenCalled();
|
||||
|
||||
window.removeEventListener('keydown', outerEscapeHandler);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ const FloatingPopup: React.FC<FloatingPopupProps> = ({
|
||||
}) => {
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const surfaceRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [portalStyle, setPortalStyle] = React.useState<React.CSSProperties | undefined>(undefined);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -93,33 +94,44 @@ const FloatingPopup: React.FC<FloatingPopupProps> = ({
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
const targetNode = event.target as Node;
|
||||
const isInsideRoot = rootRef.current?.contains(targetNode) ?? false;
|
||||
const isInsideSurface = surfaceRef.current?.contains(targetNode) ?? false;
|
||||
|
||||
if (!isInsideRoot && !isInsideSurface) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const popupSurface = isOpen ? (
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className={`floating-popup-surface ${contentClassName}`.trim()}
|
||||
data-placement={placement}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
style={renderInPortal ? portalStyle : undefined}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={`floating-popup-arrow ${arrowClassName}`.trim()}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import GlobalChordLane from './GlobalChordLane';
|
||||
import { KGChordRegion } from '../../core/region/KGChordRegion';
|
||||
|
||||
describe('GlobalChordLane', () => {
|
||||
const baseRegion = new KGChordRegion('chord-1', 'global-chord', 3, 'Cmaj7', 0, 4);
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.setProperty('--track-grid-bar-width', '40');
|
||||
});
|
||||
|
||||
it('opens the chord popup for an existing region', () => {
|
||||
const onOpenPopup = vi.fn();
|
||||
|
||||
render(
|
||||
<GlobalChordLane
|
||||
chordRegions={[baseRegion]}
|
||||
maxBars={8}
|
||||
barWidthMultiplier={1}
|
||||
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||
selectedRegionIds={[]}
|
||||
popupRegionId={null}
|
||||
onClosePopup={vi.fn()}
|
||||
onSelectRegion={vi.fn()}
|
||||
onCreateAtBeat={vi.fn()}
|
||||
onMoveRegion={vi.fn()}
|
||||
onResizeRegion={vi.fn()}
|
||||
onChangeChord={vi.fn()}
|
||||
onOpenPopup={onOpenPopup}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(screen.getByText('Cmaj7'));
|
||||
expect(onOpenPopup).toHaveBeenCalledWith('chord-1');
|
||||
});
|
||||
|
||||
it('creates a new region at a bar-aligned beat on empty-lane double click and modifier click', () => {
|
||||
const onCreateAtBeat = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<GlobalChordLane
|
||||
chordRegions={[baseRegion]}
|
||||
maxBars={8}
|
||||
barWidthMultiplier={1}
|
||||
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||
selectedRegionIds={[]}
|
||||
popupRegionId={null}
|
||||
onClosePopup={vi.fn()}
|
||||
onSelectRegion={vi.fn()}
|
||||
onCreateAtBeat={onCreateAtBeat}
|
||||
onMoveRegion={vi.fn()}
|
||||
onResizeRegion={vi.fn()}
|
||||
onChangeChord={vi.fn()}
|
||||
onOpenPopup={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const lane = container.querySelector('.global-chord-lane') as HTMLDivElement;
|
||||
vi.spyOn(lane, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 24,
|
||||
width: 320,
|
||||
height: 24,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
fireEvent.doubleClick(lane, { clientX: 159, clientY: 10 });
|
||||
fireEvent.mouseDown(lane, { clientX: 81, clientY: 10, ctrlKey: true, button: 0 });
|
||||
|
||||
expect(onCreateAtBeat).toHaveBeenNthCalledWith(1, 16);
|
||||
expect(onCreateAtBeat).toHaveBeenNthCalledWith(2, 8);
|
||||
});
|
||||
|
||||
it('snaps drag moves to whole beats', async () => {
|
||||
const onMoveRegion = vi.fn();
|
||||
|
||||
render(
|
||||
<GlobalChordLane
|
||||
chordRegions={[baseRegion]}
|
||||
maxBars={8}
|
||||
barWidthMultiplier={1}
|
||||
timeSignature={{ numerator: 4, denominator: 4 }}
|
||||
selectedRegionIds={[]}
|
||||
popupRegionId={null}
|
||||
onClosePopup={vi.fn()}
|
||||
onSelectRegion={vi.fn()}
|
||||
onCreateAtBeat={vi.fn()}
|
||||
onMoveRegion={onMoveRegion}
|
||||
onResizeRegion={vi.fn()}
|
||||
onChangeChord={vi.fn()}
|
||||
onOpenPopup={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const region = screen.getByText('Cmaj7').closest('.global-chord-region') as HTMLDivElement;
|
||||
vi.spyOn(region, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 30,
|
||||
bottom: 24,
|
||||
width: 30,
|
||||
height: 24,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
fireEvent.mouseDown(region, { clientX: 15, clientY: 10, button: 0 });
|
||||
fireEvent.mouseMove(window, { clientX: 32, clientY: 10 });
|
||||
await waitFor(() => expect(region.style.left).toBe('20px'));
|
||||
fireEvent.mouseUp(window, { clientX: 32, clientY: 10 });
|
||||
|
||||
expect(onMoveRegion).toHaveBeenCalledWith('chord-1', 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { KGChordRegion } from '../../core/region/KGChordRegion';
|
||||
import type { RegionClickOptions } from '../interfaces';
|
||||
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||
import { TOOLBAR_CONSTANTS } from '../../constants';
|
||||
import FloatingPopup from '../common/FloatingPopup';
|
||||
import ChordPickerPopup from '../ChordPickerPopup';
|
||||
|
||||
interface GlobalChordLaneProps {
|
||||
chordRegions: KGChordRegion[];
|
||||
maxBars: number;
|
||||
barWidthMultiplier: number;
|
||||
timeSignature: { numerator: number; denominator: number };
|
||||
selectedRegionIds: string[];
|
||||
popupRegionId: string | null;
|
||||
onClosePopup: () => void;
|
||||
onSelectRegion: (regionId: string, options?: RegionClickOptions) => void;
|
||||
onCreateAtBeat: (startBeat: number) => void;
|
||||
onMoveRegion: (regionId: string, startBeat: number) => void;
|
||||
onResizeRegion: (regionId: string, edge: 'start' | 'end', beat: number) => void;
|
||||
onChangeChord: (regionId: string, symbol: string) => void;
|
||||
onOpenPopup: (regionId: string) => void;
|
||||
onTabNavigate: (regionId: string, direction: 'forward' | 'backward') => void;
|
||||
}
|
||||
|
||||
type ResizeEdge = 'start' | 'end' | null;
|
||||
|
||||
const REGION_EDGE_HITBOX_PX = 8;
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
|
||||
chordRegions,
|
||||
maxBars,
|
||||
barWidthMultiplier,
|
||||
timeSignature,
|
||||
selectedRegionIds,
|
||||
popupRegionId,
|
||||
onClosePopup,
|
||||
onSelectRegion,
|
||||
onCreateAtBeat,
|
||||
onMoveRegion,
|
||||
onResizeRegion,
|
||||
onChangeChord,
|
||||
onOpenPopup,
|
||||
onTabNavigate,
|
||||
}) => {
|
||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||
const [previewBeats, setPreviewBeats] = useState<Record<string, { startBeat: number; length: number }>>({});
|
||||
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
|
||||
const [isModifierPressed, setIsModifierPressed] = useState(false);
|
||||
const interactionRef = useRef<{
|
||||
mode: 'drag' | 'resize' | null;
|
||||
regionId: string;
|
||||
initialMouseX: number;
|
||||
initialStartBeat: number;
|
||||
initialLength: number;
|
||||
resizeEdge: ResizeEdge;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const totalBeats = maxBars * timeSignature.numerator;
|
||||
const beatWidth = useMemo(() => {
|
||||
const barWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
|
||||
return barWidth / timeSignature.numerator;
|
||||
}, [barWidthMultiplier, timeSignature.numerator]);
|
||||
|
||||
const clampStartBeat = (value: number) => Math.max(0, Math.min(totalBeats - 1, value));
|
||||
const clampEndBeat = (value: number) => Math.max(1, Math.min(totalBeats, value));
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
|
||||
const getBeatFromClientX = (clientX: number, mode: 'start' | 'end' = 'start') => {
|
||||
if (!laneRef.current) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rect = laneRef.current.getBoundingClientRect();
|
||||
const relativeX = clientX - rect.left;
|
||||
const rawBeat = relativeX / beatWidth;
|
||||
return mode === 'end'
|
||||
? clampEndBeat(Math.round(rawBeat))
|
||||
: clampStartBeat(Math.round(rawBeat));
|
||||
};
|
||||
|
||||
const getBarSnappedBeatFromClientX = (clientX: number) => {
|
||||
const beat = getBeatFromClientX(clientX);
|
||||
return clampStartBeat(Math.floor(beat / beatsPerBar) * beatsPerBar);
|
||||
};
|
||||
|
||||
const getRenderedBeatState = (region: KGChordRegion) => (
|
||||
previewBeats[region.getId()] ?? {
|
||||
startBeat: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}
|
||||
);
|
||||
|
||||
const getResizeEdgeFromMouseEvent = (
|
||||
event: React.MouseEvent<HTMLDivElement, MouseEvent>
|
||||
): ResizeEdge => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
|
||||
if (offsetX <= REGION_EDGE_HITBOX_PX) {
|
||||
return 'start';
|
||||
}
|
||||
|
||||
if (rect.width - offsetX <= REGION_EDGE_HITBOX_PX) {
|
||||
return 'end';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (!isModifierKeyPressed(event)) {
|
||||
setIsModifierPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!interactionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interaction = interactionRef.current;
|
||||
const deltaX = event.clientX - interaction.initialMouseX;
|
||||
if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) {
|
||||
interaction.moved = true;
|
||||
}
|
||||
|
||||
if (interaction.mode === 'drag') {
|
||||
const beatDelta = Math.round(deltaX / beatWidth);
|
||||
const nextStartBeat = clampStartBeat(interaction.initialStartBeat + beatDelta);
|
||||
setPreviewBeats({
|
||||
[interaction.regionId]: {
|
||||
startBeat: nextStartBeat,
|
||||
length: interaction.initialLength,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.mode === 'resize') {
|
||||
const desiredBeat = getBeatFromClientX(
|
||||
event.clientX,
|
||||
interaction.resizeEdge === 'end' ? 'end' : 'start'
|
||||
);
|
||||
|
||||
if (interaction.resizeEdge === 'start') {
|
||||
const nextStartBeat = Math.min(desiredBeat, interaction.initialStartBeat + interaction.initialLength - 1);
|
||||
const endBeat = interaction.initialStartBeat + interaction.initialLength;
|
||||
setPreviewBeats({
|
||||
[interaction.regionId]: {
|
||||
startBeat: nextStartBeat,
|
||||
length: Math.max(1, endBeat - nextStartBeat),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewBeats({
|
||||
[interaction.regionId]: {
|
||||
startBeat: interaction.initialStartBeat,
|
||||
length: Math.max(1, desiredBeat - interaction.initialStartBeat),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
if (!interactionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interaction = interactionRef.current;
|
||||
interactionRef.current = null;
|
||||
|
||||
if (!interaction.moved) {
|
||||
setPreviewBeats({});
|
||||
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = previewBeats[interaction.regionId];
|
||||
setPreviewBeats({});
|
||||
|
||||
if (!preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.mode === 'drag') {
|
||||
onMoveRegion(interaction.regionId, preview.startBeat);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.mode === 'resize' && interaction.resizeEdge) {
|
||||
const beat = interaction.resizeEdge === 'start'
|
||||
? preview.startBeat
|
||||
: preview.startBeat + preview.length;
|
||||
onResizeRegion(interaction.regionId, interaction.resizeEdge, beat);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [beatWidth, onMoveRegion, onResizeRegion, onSelectRegion, previewBeats]);
|
||||
|
||||
const handleLaneMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (!(event.target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('.global-chord-region')) {
|
||||
return;
|
||||
}
|
||||
if (!isModifierKeyPressed(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
|
||||
};
|
||||
|
||||
const handleLaneDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!(event.target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('.global-chord-region')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={laneRef}
|
||||
className={`global-marker-lane global-chord-lane${popupRegionId ? ' popup-open' : ''}${isModifierPressed ? ' pencil-cursor' : ''}`}
|
||||
onMouseDown={handleLaneMouseDown}
|
||||
onDoubleClick={handleLaneDoubleClick}
|
||||
>
|
||||
{chordRegions.map(region => {
|
||||
const { startBeat, length } = getRenderedBeatState(region);
|
||||
const isSelected = selectedRegionIds.includes(region.getId());
|
||||
const left = startBeat * beatWidth;
|
||||
const width = Math.max(beatWidth, length * beatWidth);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={region.getId()}
|
||||
className={`global-marker-region global-chord-region${isSelected ? ' selected' : ''}`}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
cursor: hoverEdges[region.getId()] ? 'ew-resize' : undefined,
|
||||
zIndex: popupRegionId === region.getId() ? 1006 : 1,
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelectRegion(region.getId(), { shiftKey: false });
|
||||
onOpenPopup(region.getId());
|
||||
}}
|
||||
onMouseMove={(event) => {
|
||||
if (interactionRef.current?.regionId === region.getId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeEdge = getResizeEdgeFromMouseEvent(event);
|
||||
setHoverEdges((current) => (
|
||||
current[region.getId()] === resizeEdge
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
[region.getId()]: resizeEdge,
|
||||
}
|
||||
));
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoverEdges((current) => {
|
||||
if (!current[region.getId()]) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[region.getId()]: null,
|
||||
};
|
||||
});
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const resizeEdge = getResizeEdgeFromMouseEvent(event);
|
||||
interactionRef.current = {
|
||||
mode: resizeEdge ? 'resize' : 'drag',
|
||||
regionId: region.getId(),
|
||||
initialMouseX: event.clientX,
|
||||
initialStartBeat: region.getStartFromBeat(),
|
||||
initialLength: region.getLength(),
|
||||
resizeEdge,
|
||||
moved: false,
|
||||
};
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
|
||||
}}
|
||||
>
|
||||
<span className="global-marker-label">{region.getSymbol()}</span>
|
||||
<FloatingPopup
|
||||
isOpen={popupRegionId === region.getId()}
|
||||
onClose={onClosePopup}
|
||||
placement="bottom"
|
||||
className="global-chord-popup-anchor"
|
||||
contentClassName="global-chord-popup-surface"
|
||||
panelClassName="global-chord-popup-panel"
|
||||
arrowClassName="global-chord-popup-arrow"
|
||||
renderInPortal
|
||||
trigger={<span className="global-chord-trigger" aria-hidden="true" />}
|
||||
>
|
||||
<ChordPickerPopup
|
||||
value={region.getSymbol()}
|
||||
onChange={(symbol) => onChangeChord(region.getId(), symbol)}
|
||||
onTabNavigate={(direction) => onTabNavigate(region.getId(), direction)}
|
||||
/>
|
||||
</FloatingPopup>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalChordLane;
|
||||
Reference in New Issue
Block a user