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;
|
||||
@@ -58,7 +58,7 @@ export class KGProject {
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 14;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 15;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGChordRegion } from '../../region/KGChordRegion';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { findGlobalTrackByType, findNonOverlappingNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
|
||||
|
||||
export class CreateChordRegionCommand extends KGCommand {
|
||||
private readonly startBeat: number;
|
||||
private readonly preferredLength: number;
|
||||
private readonly symbol: string;
|
||||
private readonly regionId: string;
|
||||
private createdRegion: KGChordRegion | null = null;
|
||||
|
||||
constructor(startBeat: number, preferredLength: number, symbol: string = 'C', regionId?: string) {
|
||||
super();
|
||||
this.startBeat = startBeat;
|
||||
this.preferredLength = preferredLength;
|
||||
this.symbol = symbol;
|
||||
this.regionId = regionId ?? generateUniqueId('KGChordRegion');
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
throw new Error('Chord global track not found');
|
||||
}
|
||||
|
||||
const { maxEndBeat } = findNonOverlappingNeighborBounds(project, GlobalTrackType.Chord, null, this.startBeat);
|
||||
const songEndBeat = getSongEndBeat(project);
|
||||
const allowedEndBeat = Math.min(maxEndBeat, songEndBeat);
|
||||
const targetEndBeat = Math.min(this.startBeat + this.preferredLength, allowedEndBeat);
|
||||
const length = Math.max(1, targetEndBeat - this.startBeat);
|
||||
|
||||
this.createdRegion = new KGChordRegion(
|
||||
this.regionId,
|
||||
chordTrack.getId(),
|
||||
chordTrack.getTrackIndex(),
|
||||
this.symbol,
|
||||
this.startBeat,
|
||||
length
|
||||
);
|
||||
|
||||
chordTrack.setRegions(
|
||||
[...chordTrack.getRegions(), this.createdRegion]
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
|
||||
);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
throw new Error('Chord global track not found during undo');
|
||||
}
|
||||
|
||||
chordTrack.removeRegion(this.regionId);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Create chord "${this.symbol}"`;
|
||||
}
|
||||
|
||||
public getCreatedRegion(): KGChordRegion | null {
|
||||
return this.createdRegion;
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export class DeleteGlobalRegionCommand extends KGCommand {
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Delete marker "${this.deletedRegion?.getName() ?? this.regionId}"`;
|
||||
return `Delete global region "${this.deletedRegion?.getName() ?? this.regionId}"`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,6 @@ export class DeleteMultipleGlobalRegionsCommand extends KGCommand {
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return this.regionIds.length === 1 ? 'Delete marker' : `Delete ${this.regionIds.length} markers`;
|
||||
return this.regionIds.length === 1 ? 'Delete global region' : `Delete ${this.regionIds.length} global regions`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGProject } from '../../KGProject';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGChordRegion } from '../../region/KGChordRegion';
|
||||
import { CreateChordRegionCommand } from './CreateChordRegionCommand';
|
||||
import { InsertChordRegionAtBeatCommand } from './InsertChordRegionAtBeatCommand';
|
||||
import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
|
||||
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
|
||||
import { UpdateChordRegionCommand } from './UpdateChordRegionCommand';
|
||||
|
||||
describe('global chord region commands', () => {
|
||||
beforeEach(() => {
|
||||
const project = new KGProject('Chords', 8, 0, 120);
|
||||
const mockCore = KGCore.instance() as unknown as {
|
||||
getCurrentProject: ReturnType<typeof vi.fn>;
|
||||
getSelectedItems: ReturnType<typeof vi.fn>;
|
||||
removeSelectedItem?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
mockCore.getCurrentProject.mockReturnValue(project);
|
||||
mockCore.getSelectedItems.mockReturnValue([]);
|
||||
if (!mockCore.removeSelectedItem) {
|
||||
mockCore.removeSelectedItem = vi.fn();
|
||||
} else {
|
||||
mockCore.removeSelectedItem.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
const getChordTrack = () => {
|
||||
const chordTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
|
||||
.find(track => track.getType() === GlobalTrackType.Chord);
|
||||
|
||||
if (!chordTrack) {
|
||||
throw new Error('Chord track missing in test setup');
|
||||
}
|
||||
|
||||
return chordTrack;
|
||||
};
|
||||
|
||||
it('creates a chord region with a default one-bar length clamped by the next region', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
chordTrack.addRegion(new KGChordRegion('existing', chordTrack.getId(), chordTrack.getTrackIndex(), 'Fmaj7', 6, 2));
|
||||
|
||||
const command = new CreateChordRegionCommand(4, 4, 'Cmaj7');
|
||||
command.execute();
|
||||
|
||||
const created = command.getCreatedRegion();
|
||||
expect(created?.getStartFromBeat()).toBe(4);
|
||||
expect(created?.getLength()).toBe(2);
|
||||
});
|
||||
|
||||
it('moves and resizes chord regions with beat snapping and no overlap', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
const region = new KGChordRegion('middle', chordTrack.getId(), chordTrack.getTrackIndex(), 'Dm7', 4, 2);
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('left', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
|
||||
region,
|
||||
new KGChordRegion('right', chordTrack.getId(), chordTrack.getTrackIndex(), 'G7', 10, 2),
|
||||
]);
|
||||
|
||||
const moveCommand = new MoveGlobalRegionCommand('middle', 9);
|
||||
moveCommand.execute();
|
||||
expect(region.getStartFromBeat()).toBe(8);
|
||||
|
||||
const resizeCommand = new ResizeGlobalRegionCommand('middle', 'end', 12);
|
||||
resizeCommand.execute();
|
||||
expect(region.getLength()).toBe(2);
|
||||
|
||||
const resizeMinCommand = new ResizeGlobalRegionCommand('middle', 'start', 9);
|
||||
resizeMinCommand.execute();
|
||||
expect(region.getStartFromBeat()).toBe(9);
|
||||
expect(region.getLength()).toBe(1);
|
||||
});
|
||||
|
||||
it('updates chord symbols with undo support', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
const region = new KGChordRegion('chord', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4);
|
||||
chordTrack.setRegions([region]);
|
||||
|
||||
const command = new UpdateChordRegionCommand('chord', 'Bm7b5');
|
||||
command.execute();
|
||||
expect(region.getSymbol()).toBe('Bm7b5');
|
||||
|
||||
command.undo();
|
||||
expect(region.getSymbol()).toBe('C');
|
||||
});
|
||||
|
||||
it('inserts a new chord inside an existing region and shortens the original', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
const region = new KGChordRegion('chord', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8);
|
||||
chordTrack.setRegions([region]);
|
||||
|
||||
const command = new InsertChordRegionAtBeatCommand(3, 'C');
|
||||
command.execute();
|
||||
|
||||
const created = command.getCreatedRegion();
|
||||
expect(created).not.toBeNull();
|
||||
expect(region.getLength()).toBe(3);
|
||||
expect(created?.getStartFromBeat()).toBe(3);
|
||||
expect(created?.getLength()).toBe(5);
|
||||
|
||||
command.undo();
|
||||
expect(chordTrack.getRegions()).toHaveLength(1);
|
||||
expect(region.getLength()).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGChordRegion } from '../../region/KGChordRegion';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
import { findChordRegionAtBeat, findGlobalTrackByType } from '../../../util/globalTrackUtil';
|
||||
|
||||
export class InsertChordRegionAtBeatCommand extends KGCommand {
|
||||
private readonly insertBeat: number;
|
||||
private readonly symbol: string;
|
||||
private readonly regionId: string;
|
||||
private createdRegion: KGChordRegion | null = null;
|
||||
private targetRegionId: string | null = null;
|
||||
private originalTargetLength = 0;
|
||||
|
||||
constructor(insertBeat: number, symbol: string = 'C', regionId?: string) {
|
||||
super();
|
||||
this.insertBeat = insertBeat;
|
||||
this.symbol = symbol;
|
||||
this.regionId = regionId ?? generateUniqueId('KGChordRegion');
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
throw new Error('Chord global track not found');
|
||||
}
|
||||
|
||||
const occupiedRegion = findChordRegionAtBeat(project, this.insertBeat);
|
||||
if (!occupiedRegion) {
|
||||
throw new Error(`No chord region found at beat ${this.insertBeat}`);
|
||||
}
|
||||
|
||||
const regionStart = occupiedRegion.getStartFromBeat();
|
||||
const regionEnd = regionStart + occupiedRegion.getLength();
|
||||
if (this.insertBeat <= regionStart || this.insertBeat >= regionEnd) {
|
||||
throw new Error(`Cannot insert chord at beat ${this.insertBeat} without shrinking region below minimum length`);
|
||||
}
|
||||
|
||||
this.targetRegionId = occupiedRegion.getId();
|
||||
this.originalTargetLength = occupiedRegion.getLength();
|
||||
occupiedRegion.setLength(this.insertBeat - regionStart);
|
||||
|
||||
this.createdRegion = new KGChordRegion(
|
||||
this.regionId,
|
||||
chordTrack.getId(),
|
||||
chordTrack.getTrackIndex(),
|
||||
this.symbol,
|
||||
this.insertBeat,
|
||||
regionEnd - this.insertBeat
|
||||
);
|
||||
|
||||
chordTrack.setRegions(
|
||||
[...chordTrack.getRegions(), this.createdRegion]
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
|
||||
);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack || !this.targetRegionId || !this.createdRegion) {
|
||||
throw new Error('Cannot undo inserted chord region');
|
||||
}
|
||||
|
||||
chordTrack.removeRegion(this.createdRegion.getId());
|
||||
const targetRegion = chordTrack.getRegions().find((region): region is KGChordRegion => (
|
||||
region instanceof KGChordRegion && region.getId() === this.targetRegionId
|
||||
));
|
||||
if (!targetRegion) {
|
||||
throw new Error(`Chord region ${this.targetRegionId} not found during undo`);
|
||||
}
|
||||
|
||||
targetRegion.setLength(this.originalTargetLength);
|
||||
chordTrack.setRegions([...chordTrack.getRegions()].sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat()));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Insert chord "${this.symbol}"`;
|
||||
}
|
||||
|
||||
public getCreatedRegion(): KGChordRegion | null {
|
||||
return this.createdRegion;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds } from '../../../util/globalTrackUtil';
|
||||
import { findGlobalTrackContainingRegion, findNonOverlappingNeighborBounds } from '../../../util/globalTrackUtil';
|
||||
|
||||
export class MoveGlobalRegionCommand extends KGCommand {
|
||||
private readonly regionId: string;
|
||||
@@ -26,12 +26,17 @@ export class MoveGlobalRegionCommand extends KGCommand {
|
||||
this.targetRegion = result.region;
|
||||
this.originalStartBeat = result.region.getStartFromBeat();
|
||||
|
||||
if (result.track.getType() !== GlobalTrackType.Marker) {
|
||||
if (result.track.getType() !== GlobalTrackType.Marker && result.track.getType() !== GlobalTrackType.Chord) {
|
||||
result.region.setStartFromBeat(this.desiredStartBeat);
|
||||
return;
|
||||
}
|
||||
|
||||
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.desiredStartBeat);
|
||||
const { minStartBeat, maxEndBeat } = findNonOverlappingNeighborBounds(
|
||||
project,
|
||||
result.track.getType() as GlobalTrackType.Marker | GlobalTrackType.Chord,
|
||||
this.regionId,
|
||||
this.desiredStartBeat
|
||||
);
|
||||
const maxStartBeat = Math.max(minStartBeat, maxEndBeat - result.region.getLength());
|
||||
const clampedStartBeat = Math.max(minStartBeat, Math.min(this.desiredStartBeat, maxStartBeat));
|
||||
result.region.setStartFromBeat(clampedStartBeat);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
|
||||
import { findGlobalTrackContainingRegion, findNonOverlappingNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
|
||||
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
|
||||
|
||||
export type GlobalRegionResizeEdge = 'start' | 'end';
|
||||
@@ -32,12 +32,17 @@ export class ResizeGlobalRegionCommand extends KGCommand {
|
||||
this.originalStartBeat = result.region.getStartFromBeat();
|
||||
this.originalLength = result.region.getLength();
|
||||
|
||||
if (result.track.getType() !== GlobalTrackType.Marker) {
|
||||
if (result.track.getType() !== GlobalTrackType.Marker && result.track.getType() !== GlobalTrackType.Chord) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalEndBeat = this.originalStartBeat + this.originalLength;
|
||||
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.originalStartBeat);
|
||||
const { minStartBeat, maxEndBeat } = findNonOverlappingNeighborBounds(
|
||||
project,
|
||||
result.track.getType() as GlobalTrackType.Marker | GlobalTrackType.Chord,
|
||||
this.regionId,
|
||||
this.originalStartBeat
|
||||
);
|
||||
const songEndBeat = getSongEndBeat(project);
|
||||
const absoluteMaxEndBeat = Math.min(maxEndBeat, songEndBeat);
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
|
||||
import { KGChordRegion } from '../../region/KGChordRegion';
|
||||
|
||||
export class UpdateChordRegionCommand extends KGCommand {
|
||||
private readonly regionId: string;
|
||||
private readonly nextSymbol: string;
|
||||
private previousSymbol: string | null = null;
|
||||
|
||||
constructor(regionId: string, nextSymbol: string) {
|
||||
super();
|
||||
this.regionId = regionId;
|
||||
this.nextSymbol = nextSymbol;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
|
||||
if (!result || !(result.region instanceof KGChordRegion)) {
|
||||
throw new Error(`Chord region with ID ${this.regionId} not found`);
|
||||
}
|
||||
|
||||
this.previousSymbol = result.region.getSymbol();
|
||||
result.region.setSymbol(this.nextSymbol);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
|
||||
if (!result || !(result.region instanceof KGChordRegion) || this.previousSymbol === null) {
|
||||
throw new Error(`Chord region with ID ${this.regionId} not found during undo`);
|
||||
}
|
||||
|
||||
result.region.setSymbol(this.previousSymbol);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Change chord to "${this.nextSymbol}"`;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
|
||||
|
||||
// Global region commands
|
||||
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
|
||||
export { CreateChordRegionCommand } from './global-region/CreateChordRegionCommand';
|
||||
export { InsertChordRegionAtBeatCommand } from './global-region/InsertChordRegionAtBeatCommand';
|
||||
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
|
||||
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
|
||||
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
|
||||
@@ -44,6 +46,7 @@ export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignat
|
||||
export { ResizeTempoRegionCommand } from './global-region/ResizeTempoRegionCommand';
|
||||
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
|
||||
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
|
||||
export { UpdateChordRegionCommand } from './global-region/UpdateChordRegionCommand';
|
||||
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
|
||||
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
|
||||
export { DeleteTempoRegionCommand, DeleteMultipleTempoRegionsCommand } from './global-region/DeleteTempoRegionCommand';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import { KGGlobalRegion } from '../region/KGGlobalRegion';
|
||||
import { KGChordRegion } from '../region/KGChordRegion';
|
||||
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
||||
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
||||
import { KGTempoRegion } from '../region/KGTempoRegion';
|
||||
@@ -36,6 +37,7 @@ export class KGGlobalTrack {
|
||||
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
|
||||
{ value: KGTempoRegion, name: 'KGTempoRegion' },
|
||||
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
|
||||
{ value: KGChordRegion, name: 'KGChordRegion' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { KGProject } from '../KGProject';
|
||||
import { GlobalTrackType } from '../global-track';
|
||||
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
|
||||
import { KGMarkerRegion } from '../region/KGMarkerRegion';
|
||||
import { KGChordRegion } from '../region/KGChordRegion';
|
||||
import { KGTrack } from '../track/KGTrack';
|
||||
|
||||
// --- OPFS mock infrastructure ---
|
||||
@@ -199,6 +200,24 @@ describe('KGProjectStorage', () => {
|
||||
expect((loadedSignatureTrack?.getRegions()[0] as KGKeySignatureRegion).getStartBar()).toBe(4);
|
||||
});
|
||||
|
||||
it('preserves chord regions when saving and loading', async () => {
|
||||
const project = createTestProject('Chord Song');
|
||||
const chordTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Chord);
|
||||
|
||||
expect(chordTrack).toBeDefined();
|
||||
chordTrack?.addRegion(new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Bm7b5', 5, 3));
|
||||
|
||||
await storage.save('Chord Song', project);
|
||||
|
||||
const loaded = await storage.load('Chord Song');
|
||||
const loadedChordTrack = loaded?.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Chord);
|
||||
|
||||
expect(loadedChordTrack).toBeDefined();
|
||||
expect(loadedChordTrack?.getRegions()).toHaveLength(1);
|
||||
expect(loadedChordTrack?.getRegions()[0]).toBeInstanceOf(KGChordRegion);
|
||||
expect((loadedChordTrack?.getRegions()[0] as KGChordRegion).getSymbol()).toBe('Bm7b5');
|
||||
});
|
||||
|
||||
it('creates meta.json and media/ directory on save', async () => {
|
||||
const project = createTestProject('My Song');
|
||||
await storage.save('My Song', project);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { upgradeToV11 } from './upgradeToV11';
|
||||
import { upgradeToV12 } from './upgradeToV12';
|
||||
import { upgradeToV13 } from './upgradeToV13';
|
||||
import { upgradeToV14 } from './upgradeToV14';
|
||||
import { upgradeToV15 } from './upgradeToV15';
|
||||
|
||||
/**
|
||||
* Upgrade the given project to the latest structure version, one version at a time.
|
||||
@@ -88,6 +89,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
||||
workingProject = upgradeToV14(workingProject);
|
||||
break;
|
||||
}
|
||||
case 15: {
|
||||
workingProject = upgradeToV15(workingProject);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { upgradeProjectToLatest } from './KGProjectUpgrader';
|
||||
import { upgradeToV15 } from './upgradeToV15';
|
||||
|
||||
describe('upgradeToV15', () => {
|
||||
it('marks projects as upgraded and preserves default global tracks', () => {
|
||||
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 14, 1, []);
|
||||
|
||||
upgradeToV15(project);
|
||||
|
||||
expect(project.getProjectStructureVersion()).toBe(15);
|
||||
expect(project.getGlobalTracks()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('runs through the main upgrader path', () => {
|
||||
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 14, 1, []);
|
||||
|
||||
const upgraded = upgradeProjectToLatest(project);
|
||||
|
||||
expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { KGProject } from '../KGProject';
|
||||
import { ensureDefaultGlobalTracks } from '../../util/globalTrackUtil';
|
||||
|
||||
export function upgradeToV15(project: KGProject): KGProject {
|
||||
try {
|
||||
ensureDefaultGlobalTracks(project);
|
||||
} finally {
|
||||
project.setProjectStructureVersion(15);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Expose } from 'class-transformer';
|
||||
import { KGGlobalRegion } from './KGGlobalRegion';
|
||||
|
||||
export class KGChordRegion extends KGGlobalRegion {
|
||||
@Expose()
|
||||
protected override __type: string = 'KGChordRegion';
|
||||
|
||||
@Expose()
|
||||
private symbol: string = 'C';
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
trackId: string,
|
||||
trackIndex: number,
|
||||
symbol: string,
|
||||
startFromBeat: number = 0,
|
||||
length: number = 0
|
||||
) {
|
||||
super(id, trackId, trackIndex, symbol, startFromBeat, length);
|
||||
this.__type = 'KGChordRegion';
|
||||
this.symbol = symbol;
|
||||
super.setName(symbol);
|
||||
}
|
||||
|
||||
public getSymbol(): string {
|
||||
return this.symbol;
|
||||
}
|
||||
|
||||
public setSymbol(symbol: string): void {
|
||||
this.symbol = symbol;
|
||||
super.setName(symbol);
|
||||
}
|
||||
|
||||
public override getCurrentType(): string {
|
||||
return 'KGChordRegion';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildChordSymbol, formatChordSymbolForDisplay, getChordMidiPitches, getChordPitchClasses, parseChordSymbol } from './chordUtil';
|
||||
|
||||
describe('chordUtil', () => {
|
||||
it('parses half-diminished chords into the popup descriptor shape', () => {
|
||||
const parsed = parseChordSymbol('Bm7b5');
|
||||
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.root).toBe('B');
|
||||
expect(parsed?.quality).toBe('dim');
|
||||
expect(parsed?.extensions).toContain('b5');
|
||||
expect(parsed?.extensions).toContain('7');
|
||||
expect(parsed?.symbol).toBe('Bm7b5');
|
||||
});
|
||||
|
||||
it('preserves enharmonic root spelling in the canonical symbol', () => {
|
||||
expect(buildChordSymbol({
|
||||
root: 'Bb',
|
||||
quality: 'maj',
|
||||
extensions: ['7'],
|
||||
})).toBe('Bb7');
|
||||
|
||||
expect(buildChordSymbol({
|
||||
root: 'A#',
|
||||
quality: 'maj',
|
||||
extensions: ['7'],
|
||||
})).toBe('A#7');
|
||||
});
|
||||
|
||||
it('rejects unsupported symbols deterministically', () => {
|
||||
expect(parseChordSymbol('C/E')).toBeNull();
|
||||
expect(parseChordSymbol('not-a-chord')).toBeNull();
|
||||
});
|
||||
|
||||
it('derives stable pitch classes and midi pitches from the stored symbol', () => {
|
||||
expect(getChordPitchClasses('Bm7b5')).toEqual([11, 2, 5, 9]);
|
||||
expect(getChordMidiPitches('Bm7b5', 59)).toEqual([59, 62, 65, 69]);
|
||||
});
|
||||
|
||||
it('formats the preview using standard chord display conventions', () => {
|
||||
expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
|
||||
expect(formatChordSymbolForDisplay('Bbmaj7#11')).toBe('B♭maj7(♯11)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import { Chord, Interval, Note } from 'tonal';
|
||||
|
||||
export type ChordQuality = 'maj' | 'min' | 'sus2' | 'sus4' | 'power' | 'aug' | 'dim';
|
||||
export type ChordExtension =
|
||||
| 'b5'
|
||||
| '#5'
|
||||
| '6'
|
||||
| '7'
|
||||
| 'maj7'
|
||||
| 'b9'
|
||||
| '9'
|
||||
| '#9'
|
||||
| '11'
|
||||
| '#11'
|
||||
| 'b13'
|
||||
| '13';
|
||||
|
||||
export interface ChordDescriptor {
|
||||
root: string;
|
||||
quality: ChordQuality;
|
||||
extensions: ChordExtension[];
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
const ROOT_PATTERN = /^[A-G](?:#|b)?$/;
|
||||
const EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', '6', '7', 'maj7', 'b9', '9', '#9', '11', '#11', 'b13', '13'];
|
||||
const REMAINING_EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', 'b9', '9', '#9', '11', '#11', 'b13', '13'];
|
||||
const ADD_EXTENSION_ORDER: ChordExtension[] = ['b9', '9', '#9', '11', '#11', 'b13', '13'];
|
||||
const CUSTOM_TOKENS = [
|
||||
'maj7#5',
|
||||
'm7b5',
|
||||
'sus2',
|
||||
'sus4',
|
||||
'aug',
|
||||
'dim',
|
||||
'maj7',
|
||||
'add#11',
|
||||
'addb13',
|
||||
'add13',
|
||||
'add11',
|
||||
'add#9',
|
||||
'addb9',
|
||||
'add9',
|
||||
'#11',
|
||||
'b13',
|
||||
'#9',
|
||||
'b9',
|
||||
'9',
|
||||
'13',
|
||||
'11',
|
||||
'#5',
|
||||
'b5',
|
||||
'7',
|
||||
'6',
|
||||
'm',
|
||||
'5',
|
||||
] as const;
|
||||
|
||||
function normalizeRoot(root: string): string | null {
|
||||
const normalized = root.trim();
|
||||
if (!ROOT_PATTERN.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Note.get(normalized);
|
||||
return parsed.empty || parsed.acc.length > 1 ? null : parsed.pc;
|
||||
}
|
||||
|
||||
function sortExtensions(extensions: Iterable<ChordExtension>): ChordExtension[] {
|
||||
const unique = new Set(extensions);
|
||||
return EXTENSION_ORDER.filter(extension => unique.has(extension));
|
||||
}
|
||||
|
||||
function hasExtension(descriptor: Pick<ChordDescriptor, 'extensions'>, extension: ChordExtension): boolean {
|
||||
return descriptor.extensions.includes(extension);
|
||||
}
|
||||
|
||||
function getDescriptorIntervals(descriptor: Pick<ChordDescriptor, 'quality' | 'extensions'>): string[] {
|
||||
const intervals = ['1P'];
|
||||
|
||||
switch (descriptor.quality) {
|
||||
case 'maj':
|
||||
intervals.push('3M', '5P');
|
||||
break;
|
||||
case 'min':
|
||||
intervals.push('3m', '5P');
|
||||
break;
|
||||
case 'sus2':
|
||||
intervals.push('2M', '5P');
|
||||
break;
|
||||
case 'sus4':
|
||||
intervals.push('4P', '5P');
|
||||
break;
|
||||
case 'power':
|
||||
intervals.push('5P');
|
||||
break;
|
||||
case 'aug':
|
||||
intervals.push('3M', '5A');
|
||||
break;
|
||||
case 'dim':
|
||||
intervals.push('3m', '5d');
|
||||
break;
|
||||
}
|
||||
|
||||
for (const extension of descriptor.extensions) {
|
||||
switch (extension) {
|
||||
case 'b5':
|
||||
if (!intervals.includes('5d')) {
|
||||
intervals.push('5d');
|
||||
}
|
||||
break;
|
||||
case '#5':
|
||||
if (!intervals.includes('5A')) {
|
||||
intervals.push('5A');
|
||||
}
|
||||
break;
|
||||
case '6':
|
||||
intervals.push('6M');
|
||||
break;
|
||||
case '7':
|
||||
intervals.push('7m');
|
||||
break;
|
||||
case 'maj7':
|
||||
intervals.push('7M');
|
||||
break;
|
||||
case 'b9':
|
||||
intervals.push('9m');
|
||||
break;
|
||||
case '9':
|
||||
intervals.push('9M');
|
||||
break;
|
||||
case '#9':
|
||||
intervals.push('9A');
|
||||
break;
|
||||
case '11':
|
||||
intervals.push('11P');
|
||||
break;
|
||||
case '#11':
|
||||
intervals.push('11A');
|
||||
break;
|
||||
case 'b13':
|
||||
intervals.push('13m');
|
||||
break;
|
||||
case '13':
|
||||
intervals.push('13M');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(intervals));
|
||||
}
|
||||
|
||||
function parseIntervalsToDescriptor(root: string, intervals: string[]): ChordDescriptor | null {
|
||||
const intervalSet = new Set(intervals);
|
||||
let quality: ChordQuality | null = null;
|
||||
|
||||
if (intervalSet.has('5A')) {
|
||||
quality = 'aug';
|
||||
} else if (intervalSet.has('2M') && !intervalSet.has('3m') && !intervalSet.has('3M') && !intervalSet.has('4P') && intervalSet.has('5P')) {
|
||||
quality = 'sus2';
|
||||
} else if (intervalSet.has('4P') && !intervalSet.has('3m') && !intervalSet.has('3M') && intervalSet.has('5P')) {
|
||||
quality = 'sus4';
|
||||
} else if (intervalSet.has('3m') && intervalSet.has('5d')) {
|
||||
quality = 'dim';
|
||||
} else if (intervalSet.has('3m')) {
|
||||
quality = 'min';
|
||||
} else if (intervalSet.has('3M')) {
|
||||
quality = 'maj';
|
||||
} else if (intervalSet.has('5P')) {
|
||||
quality = 'power';
|
||||
}
|
||||
|
||||
if (!quality) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (intervalSet.has('7d') || intervalSet.has('3A') || intervalSet.has('4d')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensions = new Set<ChordExtension>();
|
||||
if (intervalSet.has('5d')) {
|
||||
extensions.add('b5');
|
||||
}
|
||||
if (intervalSet.has('5A')) {
|
||||
extensions.add('#5');
|
||||
}
|
||||
if (intervalSet.has('6M')) {
|
||||
extensions.add('6');
|
||||
}
|
||||
if (intervalSet.has('7m')) {
|
||||
extensions.add('7');
|
||||
}
|
||||
if (intervalSet.has('7M')) {
|
||||
extensions.add('maj7');
|
||||
}
|
||||
if (intervalSet.has('9m') || intervalSet.has('2m')) {
|
||||
extensions.add('b9');
|
||||
}
|
||||
if (intervalSet.has('9M') || (intervalSet.has('2M') && quality !== 'sus2')) {
|
||||
extensions.add('9');
|
||||
}
|
||||
if (intervalSet.has('9A')) {
|
||||
extensions.add('#9');
|
||||
}
|
||||
if (intervalSet.has('11P') && quality !== 'sus4') {
|
||||
extensions.add('11');
|
||||
}
|
||||
if (intervalSet.has('11A')) {
|
||||
extensions.add('#11');
|
||||
}
|
||||
if (intervalSet.has('13m') || intervalSet.has('6m')) {
|
||||
extensions.add('b13');
|
||||
}
|
||||
if (intervalSet.has('13M')) {
|
||||
extensions.add('13');
|
||||
}
|
||||
|
||||
const descriptor: ChordDescriptor = {
|
||||
root,
|
||||
quality,
|
||||
extensions: sortExtensions(extensions),
|
||||
symbol: '',
|
||||
};
|
||||
|
||||
const symbol = buildChordSymbol(descriptor);
|
||||
if (!symbol) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...descriptor,
|
||||
symbol,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCustomChordSymbol(symbol: string): ChordDescriptor | null {
|
||||
const trimmed = symbol.trim();
|
||||
const rootMatch = trimmed.match(/^([A-G](?:#|b)?)(.*)$/);
|
||||
if (!rootMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = normalizeRoot(rootMatch[1]);
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let remainder = rootMatch[2];
|
||||
let quality: ChordQuality = 'maj';
|
||||
const extensions = new Set<ChordExtension>();
|
||||
|
||||
if (remainder.startsWith('m7b5')) {
|
||||
quality = 'dim';
|
||||
extensions.add('b5');
|
||||
extensions.add('7');
|
||||
remainder = remainder.slice(4);
|
||||
} else if (remainder.startsWith('maj7#5')) {
|
||||
quality = 'aug';
|
||||
extensions.add('#5');
|
||||
extensions.add('maj7');
|
||||
remainder = remainder.slice(6);
|
||||
} else if (remainder.startsWith('7#5')) {
|
||||
quality = 'aug';
|
||||
extensions.add('#5');
|
||||
extensions.add('7');
|
||||
remainder = remainder.slice(3);
|
||||
} else if (remainder.startsWith('sus2')) {
|
||||
quality = 'sus2';
|
||||
remainder = remainder.slice(4);
|
||||
} else if (remainder.startsWith('sus4')) {
|
||||
quality = 'sus4';
|
||||
remainder = remainder.slice(4);
|
||||
} else if (remainder.startsWith('aug')) {
|
||||
quality = 'aug';
|
||||
remainder = remainder.slice(3);
|
||||
} else if (remainder.startsWith('dim')) {
|
||||
quality = 'dim';
|
||||
remainder = remainder.slice(3);
|
||||
} else if (remainder.startsWith('m')) {
|
||||
quality = 'min';
|
||||
remainder = remainder.slice(1);
|
||||
} else if (remainder.startsWith('5')) {
|
||||
quality = 'power';
|
||||
remainder = remainder.slice(1);
|
||||
}
|
||||
|
||||
if (remainder.startsWith('maj7')) {
|
||||
extensions.add('maj7');
|
||||
remainder = remainder.slice(4);
|
||||
} else if (remainder.startsWith('7')) {
|
||||
extensions.add('7');
|
||||
remainder = remainder.slice(1);
|
||||
} else if (remainder.startsWith('6')) {
|
||||
extensions.add('6');
|
||||
remainder = remainder.slice(1);
|
||||
}
|
||||
|
||||
while (remainder.length > 0) {
|
||||
const nextToken = CUSTOM_TOKENS.find(token => remainder.startsWith(token));
|
||||
if (!nextToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (nextToken) {
|
||||
case 'b5':
|
||||
extensions.add('b5');
|
||||
break;
|
||||
case '#5':
|
||||
extensions.add('#5');
|
||||
break;
|
||||
case 'b9':
|
||||
case 'addb9':
|
||||
extensions.add('b9');
|
||||
break;
|
||||
case '9':
|
||||
case 'add9':
|
||||
extensions.add('9');
|
||||
break;
|
||||
case '#9':
|
||||
case 'add#9':
|
||||
extensions.add('#9');
|
||||
break;
|
||||
case '11':
|
||||
case 'add11':
|
||||
extensions.add('11');
|
||||
break;
|
||||
case '#11':
|
||||
case 'add#11':
|
||||
extensions.add('#11');
|
||||
break;
|
||||
case 'b13':
|
||||
case 'addb13':
|
||||
extensions.add('b13');
|
||||
break;
|
||||
case '13':
|
||||
case 'add13':
|
||||
extensions.add('13');
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
remainder = remainder.slice(nextToken.length);
|
||||
}
|
||||
|
||||
const descriptor: ChordDescriptor = {
|
||||
root,
|
||||
quality,
|
||||
extensions: sortExtensions(extensions),
|
||||
symbol: '',
|
||||
};
|
||||
|
||||
const canonical = buildChordSymbol(descriptor);
|
||||
if (!canonical) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...descriptor,
|
||||
symbol: canonical,
|
||||
};
|
||||
}
|
||||
|
||||
function tokenizeTonalIntervals(intervals: string[]): string[] | null {
|
||||
const normalized = intervals
|
||||
.map(interval => interval.replace(/^(\d+)([PmMdA])$/, '$1$2').replace(/^(\d+)([dm])$/, '$1$2'))
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'quality' | 'extensions'>): string | null {
|
||||
const root = normalizeRoot(descriptor.root);
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensions = sortExtensions(descriptor.extensions);
|
||||
const has = (extension: ChordExtension) => extensions.includes(extension);
|
||||
const hasSeventh = has('7') || has('maj7');
|
||||
const remainingExtensions = new Set(extensions);
|
||||
|
||||
let symbol = root;
|
||||
|
||||
if (descriptor.quality === 'dim' && has('7')) {
|
||||
symbol += 'm7b5';
|
||||
remainingExtensions.delete('7');
|
||||
remainingExtensions.delete('b5');
|
||||
} else if (descriptor.quality === 'aug' && has('7')) {
|
||||
symbol += '7#5';
|
||||
remainingExtensions.delete('7');
|
||||
remainingExtensions.delete('#5');
|
||||
} else if (descriptor.quality === 'aug' && has('maj7')) {
|
||||
symbol += 'maj7#5';
|
||||
remainingExtensions.delete('maj7');
|
||||
remainingExtensions.delete('#5');
|
||||
} else {
|
||||
switch (descriptor.quality) {
|
||||
case 'maj':
|
||||
break;
|
||||
case 'min':
|
||||
symbol += 'm';
|
||||
break;
|
||||
case 'sus2':
|
||||
symbol += 'sus2';
|
||||
break;
|
||||
case 'sus4':
|
||||
symbol += 'sus4';
|
||||
break;
|
||||
case 'power':
|
||||
symbol += '5';
|
||||
break;
|
||||
case 'aug':
|
||||
symbol += 'aug';
|
||||
remainingExtensions.delete('#5');
|
||||
break;
|
||||
case 'dim':
|
||||
symbol += 'dim';
|
||||
remainingExtensions.delete('b5');
|
||||
break;
|
||||
}
|
||||
|
||||
if (has('maj7')) {
|
||||
symbol += 'maj7';
|
||||
remainingExtensions.delete('maj7');
|
||||
} else if (has('7')) {
|
||||
symbol += '7';
|
||||
remainingExtensions.delete('7');
|
||||
} else if (has('6')) {
|
||||
symbol += '6';
|
||||
remainingExtensions.delete('6');
|
||||
}
|
||||
}
|
||||
|
||||
for (const extension of REMAINING_EXTENSION_ORDER) {
|
||||
if (!remainingExtensions.has(extension)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ADD_EXTENSION_ORDER.includes(extension) && !hasSeventh) {
|
||||
symbol += `add${extension}`;
|
||||
} else {
|
||||
symbol += extension;
|
||||
}
|
||||
}
|
||||
|
||||
return symbol;
|
||||
}
|
||||
|
||||
export function parseChordSymbol(symbol: string): ChordDescriptor | null {
|
||||
const trimmed = symbol.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tonalChord = Chord.get(trimmed);
|
||||
if (tonalChord && !tonalChord.empty && tonalChord.tonic && !tonalChord.bass && !tonalChord.root) {
|
||||
const root = normalizeRoot(tonalChord.tonic);
|
||||
const intervals = tokenizeTonalIntervals(tonalChord.intervals);
|
||||
if (root && intervals) {
|
||||
const parsed = parseIntervalsToDescriptor(root, intervals);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parseCustomChordSymbol(trimmed);
|
||||
}
|
||||
|
||||
export function getChordPitchClasses(symbol: string): number[] {
|
||||
const descriptor = parseChordSymbol(symbol);
|
||||
if (!descriptor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const root = Note.get(descriptor.root);
|
||||
if (root.empty || root.chroma === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getDescriptorIntervals(descriptor).map((interval) => {
|
||||
const semitones = Interval.semitones(interval);
|
||||
return (root.chroma + semitones + 120) % 12;
|
||||
});
|
||||
}
|
||||
|
||||
export function getChordMidiPitches(symbol: string, rootMidi: number): number[] {
|
||||
const descriptor = parseChordSymbol(symbol);
|
||||
if (!descriptor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getDescriptorIntervals(descriptor).map((interval) => rootMidi + Interval.semitones(interval));
|
||||
}
|
||||
|
||||
export function formatChordSymbolForDisplay(symbol: string): string {
|
||||
const descriptor = parseChordSymbol(symbol);
|
||||
if (!descriptor) {
|
||||
return symbol;
|
||||
}
|
||||
|
||||
const { root, quality, extensions } = descriptor;
|
||||
const accidentalDisplay = (value: string) => value.replace(/b/g, '♭').replace(/#/g, '♯');
|
||||
const baseQuality = (() => {
|
||||
switch (quality) {
|
||||
case 'maj':
|
||||
return '';
|
||||
case 'min':
|
||||
return 'm';
|
||||
case 'sus2':
|
||||
return 'sus2';
|
||||
case 'sus4':
|
||||
return 'sus4';
|
||||
case 'power':
|
||||
return '5';
|
||||
case 'aug':
|
||||
return 'aug';
|
||||
case 'dim':
|
||||
return 'm';
|
||||
}
|
||||
})();
|
||||
|
||||
const inlineExtensions: string[] = [];
|
||||
const parentheticalExtensions: string[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (extension === '7' || extension === 'maj7' || extension === '6') {
|
||||
inlineExtensions.push(extension);
|
||||
continue;
|
||||
}
|
||||
|
||||
parentheticalExtensions.push(accidentalDisplay(extension));
|
||||
}
|
||||
|
||||
const inlineText = inlineExtensions.join('');
|
||||
const parentheticalText = parentheticalExtensions.length > 0
|
||||
? `(${parentheticalExtensions.join(', ')})`
|
||||
: '';
|
||||
|
||||
return `${accidentalDisplay(root)}${baseQuality}${inlineText}${parentheticalText}`;
|
||||
}
|
||||
|
||||
export const CHORD_QUALITY_OPTIONS: ChordQuality[] = ['maj', 'min', 'sus2', 'sus4', 'power', 'aug', 'dim'];
|
||||
export const CHORD_EXTENSION_OPTIONS: ChordExtension[] = EXTENSION_ORDER;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createDefaultGlobalTracks,
|
||||
} from '../core/global-track';
|
||||
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
|
||||
import { KGChordRegion } from '../core/region/KGChordRegion';
|
||||
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
|
||||
import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { KGTempoRegion } from '../core/region/KGTempoRegion';
|
||||
@@ -59,14 +60,23 @@ export function findMarkerNeighborBounds(
|
||||
regionId: string | null,
|
||||
proposedStartBeat: number
|
||||
): { minStartBeat: number; maxEndBeat: number; nextStartBeat: number | null } {
|
||||
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
return findNonOverlappingNeighborBounds(project, GlobalTrackType.Marker, regionId, proposedStartBeat);
|
||||
}
|
||||
|
||||
export function findNonOverlappingNeighborBounds(
|
||||
project: KGProject,
|
||||
trackType: GlobalTrackType.Marker | GlobalTrackType.Chord,
|
||||
regionId: string | null,
|
||||
proposedStartBeat: number
|
||||
): { minStartBeat: number; maxEndBeat: number; nextStartBeat: number | null } {
|
||||
const track = findGlobalTrackByType(project, trackType);
|
||||
const songEndBeat = getSongEndBeat(project);
|
||||
|
||||
if (!markerTrack) {
|
||||
if (!track) {
|
||||
return { minStartBeat: 0, maxEndBeat: songEndBeat, nextStartBeat: null };
|
||||
}
|
||||
|
||||
const otherRegions = markerTrack.getRegions()
|
||||
const otherRegions = track.getRegions()
|
||||
.filter(region => region.getId() !== regionId)
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
|
||||
@@ -88,6 +98,17 @@ export function findMarkerNeighborBounds(
|
||||
return { minStartBeat, maxEndBeat, nextStartBeat };
|
||||
}
|
||||
|
||||
export function findChordRegionAtBeat(project: KGProject, beat: number): KGChordRegion | null {
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!track) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return track.getRegions()
|
||||
.filter((region): region is KGChordRegion => region instanceof KGChordRegion)
|
||||
.find(region => beat >= region.getStartFromBeat() && beat < region.getStartFromBeat() + region.getLength()) ?? null;
|
||||
}
|
||||
|
||||
export function getSongEndBar(project: KGProject): number {
|
||||
return project.getMaxBars();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user