feat: implement global chord track

This commit is contained in:
Xiaohan-Tian
2026-05-24 23:06:08 -07:00
parent 77fb0e4c1f
commit 1879df8033
30 changed files with 2757 additions and 20 deletions
+168
View File
@@ -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));
}
}
+75
View File
@@ -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();
});
});
+317
View File
@@ -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;
+42
View File
@@ -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;
+86 -2
View File
@@ -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', () => {
+184 -1
View File
@@ -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);
});
});
+15 -3
View File
@@ -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;