feat: implemented user-friendly chord guide editor; added real time chord guide info to the status bar
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import StatusBar from './StatusBar';
|
||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import type { ChordGuideData } from '../core/ChordGuideTypes';
|
||||
import { resolveChordGuideItems } from '../util/chordGuideDataUtil';
|
||||
import type { ResolvedChordGuideItem } from '../core/ChordGuideTypes';
|
||||
import chordGuideDataJson from '../../public/resources/modes/chord_guide.json';
|
||||
|
||||
const chordGuideData = chordGuideDataJson as ChordGuideData;
|
||||
|
||||
const storeState = {
|
||||
currentStatus: 'Ready',
|
||||
};
|
||||
|
||||
type StoreState = typeof storeState;
|
||||
type StoreSelector = (state: StoreState) => unknown;
|
||||
|
||||
vi.mock('../stores/projectStore', () => ({
|
||||
useProjectStore: Object.assign(
|
||||
(selector?: StoreSelector) => (selector ? selector(storeState) : storeState),
|
||||
{ getState: () => storeState }
|
||||
),
|
||||
}));
|
||||
|
||||
describe('StatusBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('__APP_VERSION__', 'test-version');
|
||||
storeState.currentStatus = 'Ready';
|
||||
KGCore.CHORD_GUIDE_DATA = structuredClone(chordGuideData);
|
||||
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null);
|
||||
});
|
||||
|
||||
it('shows the normal status when no chord-guide candidate is hovered', () => {
|
||||
render(<StatusBar />);
|
||||
|
||||
expect(screen.getByText('Ready')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows hovered chord-guide candidate text and restores the prior status when cleared', () => {
|
||||
render(<StatusBar />);
|
||||
|
||||
const hoveredCandidate: ResolvedChordGuideItem = {
|
||||
name: 'Dm7',
|
||||
roman: 'ii7',
|
||||
notes: ['D', 'F', 'A', 'C'],
|
||||
source: 'Diatonic',
|
||||
note: 'Core ii-V-I predominant sonority.',
|
||||
resolvedNotes: ['D', 'F', 'A', 'C'],
|
||||
pitchClasses: [2, 5, 9, 12],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(hoveredCandidate);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('Chord Guide Candidate: Dm7 — D F A C — Core ii-V-I predominant sonority.')
|
||||
).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Ready')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses resolved notes from the current key signature in the status text', () => {
|
||||
const transposedCandidate = resolveChordGuideItems('D major', 'ionian', 'T')[0];
|
||||
|
||||
render(<StatusBar />);
|
||||
|
||||
act(() => {
|
||||
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(transposedCandidate);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('Chord Guide Candidate: C — D F# A — Most stable tonic triad.')
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,28 @@
|
||||
import React from 'react';
|
||||
import React, { useSyncExternalStore } from 'react';
|
||||
import './StatusBar.css';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||
import type { ResolvedChordGuideItem } from '../core/ChordGuideTypes';
|
||||
|
||||
function formatChordGuideCandidateStatus(candidate: ResolvedChordGuideItem): string {
|
||||
return `Chord Guide Candidate: ${candidate.name} — ${candidate.resolvedNotes.join(' ')} — ${candidate.note}`;
|
||||
}
|
||||
|
||||
const StatusBar: React.FC = () => {
|
||||
const { currentStatus } = useProjectStore();
|
||||
const hoveredChordGuideCandidate = useSyncExternalStore(
|
||||
(listener) => KGPianoRollState.instance().subscribe(listener),
|
||||
() => KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate(),
|
||||
() => null,
|
||||
);
|
||||
const statusText = hoveredChordGuideCandidate
|
||||
? formatChordGuideCandidateStatus(hoveredChordGuideCandidate)
|
||||
: currentStatus;
|
||||
|
||||
return (
|
||||
<div className="status-bar">
|
||||
<div className="status-left">
|
||||
{currentStatus}
|
||||
<div className="status-left" title={statusText}>
|
||||
{statusText}
|
||||
</div>
|
||||
<div className="status-right">
|
||||
<span>K.G.Studio (v{__APP_VERSION__})</span>
|
||||
@@ -17,4 +31,5 @@ const StatusBar: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBar;
|
||||
export default StatusBar;
|
||||
export { formatChordGuideCandidateStatus };
|
||||
|
||||
@@ -11,7 +11,7 @@ import AudioWaveformCanvas from './AudioWaveformCanvas';
|
||||
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
|
||||
import { getNextChordCandidateIndex } from './chordGuideUtil';
|
||||
import { getMatchingChordGuideChordsForPitch } from '../../util/chordGuideDataUtil';
|
||||
import { getMatchingChordGuideCandidatesForPitch } from '../../util/chordGuideDataUtil';
|
||||
|
||||
interface PianoGridProps {
|
||||
gridRef: MutableRefObject<HTMLDivElement | null>;
|
||||
@@ -160,24 +160,28 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
};
|
||||
|
||||
// Get all matching chords for the current hover position
|
||||
const matchingChords = useMemo(() => {
|
||||
const matchingCandidates = useMemo(() => {
|
||||
if (!cursorPosition) return [];
|
||||
|
||||
// If chord guide is disabled, return empty array
|
||||
if (chordGuide === 'N') return [];
|
||||
|
||||
// Use the utility function to get matching chords
|
||||
// Use the utility function to get matching chords in the same order used by candidate cycling.
|
||||
const functionType = chordGuide as 'T' | 'S' | 'D';
|
||||
return getMatchingChordGuideChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
|
||||
return getMatchingChordGuideCandidatesForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
|
||||
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
|
||||
|
||||
const matchingChords = useMemo(() => (
|
||||
matchingCandidates.map((candidate) => candidate.displayPitchClasses)
|
||||
), [matchingCandidates]);
|
||||
|
||||
// Calculate chord highlights based on selected chord index
|
||||
const chordHighlights = useMemo(() => {
|
||||
if (matchingChords.length === 0) return [];
|
||||
if (matchingCandidates.length === 0) return [];
|
||||
|
||||
// Use the selected chord index (wrap around if needed)
|
||||
const chordIndex = selectedChordIndex % matchingChords.length;
|
||||
const matchedChordPitches = matchingChords[chordIndex];
|
||||
const chordIndex = selectedChordIndex % matchingCandidates.length;
|
||||
const matchedChordPitches = matchingCandidates[chordIndex].displayPitchClasses;
|
||||
|
||||
// Convert pitch classes to actual pitches in the same octave as cursor
|
||||
const highlights: Array<{ pitch: number; beat: number }> = [];
|
||||
@@ -195,15 +199,22 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
}
|
||||
|
||||
return highlights;
|
||||
}, [cursorPosition, matchingChords, selectedChordIndex]);
|
||||
}, [cursorPosition, matchingCandidates, selectedChordIndex]);
|
||||
|
||||
const cursorPitch = cursorPosition?.pitch ?? null;
|
||||
const selectedHoverCandidate = useMemo(() => {
|
||||
if (matchingCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return matchingCandidates[selectedChordIndex % matchingCandidates.length].item;
|
||||
}, [matchingCandidates, selectedChordIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const pianoRollState = KGPianoRollState.instance();
|
||||
pianoRollState.setCurrentMatchingChords(matchingChords);
|
||||
pianoRollState.setCurrentChordCursorPitch(cursorPitch);
|
||||
}, [matchingChords, cursorPitch]);
|
||||
pianoRollState.setCurrentHoveredChordGuideCandidate(selectedHoverCandidate);
|
||||
}, [matchingChords, cursorPitch, selectedHoverCandidate]);
|
||||
|
||||
useEffect(() => {
|
||||
KGPianoRollState.instance().setCurrentSelectedChordIndex(selectedChordIndex);
|
||||
@@ -219,8 +230,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
// Expose switchChord function via window for hotkey handler
|
||||
useEffect(() => {
|
||||
const switchChord = (direction: 1 | -1 = 1) => {
|
||||
if (matchingChords.length > 1) {
|
||||
setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingChords.length, direction));
|
||||
if (matchingCandidates.length > 1) {
|
||||
setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingCandidates.length, direction));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,7 +243,7 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (window as any).__pianoGridSwitchChord;
|
||||
};
|
||||
}, [matchingChords.length]);
|
||||
}, [matchingCandidates.length]);
|
||||
|
||||
return (
|
||||
<div className="piano-grid-container">
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import type { ChordGuideData } from '../../core/ChordGuideTypes';
|
||||
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||
import { getMatchingChordGuideCandidatesForPitch } from '../../util/chordGuideDataUtil';
|
||||
import chordGuideDataJson from '../../../public/resources/modes/chord_guide.json';
|
||||
|
||||
const chordGuideData = chordGuideDataJson as ChordGuideData;
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
Playhead: () => null,
|
||||
}));
|
||||
|
||||
import PianoGrid from './PianoGrid';
|
||||
|
||||
describe('PianoGrid chord-guide hover candidate state', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.setProperty('--region-grid-beat-width', '40');
|
||||
document.documentElement.style.setProperty('--region-piano-key-height', '20');
|
||||
KGCore.CHORD_GUIDE_DATA = structuredClone(chordGuideData);
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = {
|
||||
ionian: { steps: [2, 2, 1, 2, 2, 2, 1] },
|
||||
aeolian: { steps: [2, 1, 2, 2, 1, 2, 2] },
|
||||
} as unknown as typeof KGCore.FUNCTIONAL_CHORDS_DATA;
|
||||
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null);
|
||||
KGPianoRollState.instance().setCurrentMatchingChords([]);
|
||||
KGPianoRollState.instance().setCurrentSelectedChordIndex(0);
|
||||
KGPianoRollState.instance().setCurrentChordCursorPitch(null);
|
||||
});
|
||||
|
||||
it('tracks the hovered candidate, updates on candidate cycling, and clears on mouse leave', async () => {
|
||||
const gridRef = { current: null as HTMLDivElement | null };
|
||||
const { container } = render(
|
||||
<PianoGrid
|
||||
gridRef={gridRef}
|
||||
onDoubleClick={() => {}}
|
||||
onClick={() => {}}
|
||||
onMouseDown={() => {}}
|
||||
isBoxSelecting={false}
|
||||
selectionBox={{ startX: 0, startY: 0, endX: 0, endY: 0 }}
|
||||
selectedMode="ionian"
|
||||
keySignature="C major"
|
||||
chordGuide="T"
|
||||
chordGuideKeySignature="C major"
|
||||
chordGuideMode="ionian"
|
||||
>
|
||||
{null}
|
||||
</PianoGrid>
|
||||
);
|
||||
|
||||
const pianoGrid = container.querySelector('.piano-grid') as HTMLDivElement;
|
||||
pianoGrid.getBoundingClientRect = () => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 1200,
|
||||
right: 800,
|
||||
bottom: 1200,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
fireEvent.mouseMove(pianoGrid, { clientX: 10, clientY: 941 });
|
||||
|
||||
const expectedCandidates = getMatchingChordGuideCandidatesForPitch(60, 'C major', 'ionian', 'T');
|
||||
expect(expectedCandidates.length).toBeGreaterThan(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toMatchObject({
|
||||
name: expectedCandidates[0].item.name,
|
||||
resolvedNotes: expectedCandidates[0].item.resolvedNotes,
|
||||
note: expectedCandidates[0].item.note,
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).__pianoGridSwitchChord(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toMatchObject({
|
||||
name: expectedCandidates[1].item.name,
|
||||
resolvedNotes: expectedCandidates[1].item.resolvedNotes,
|
||||
note: expectedCandidates[1].item.note,
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.mouseLeave(pianoGrid);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -741,6 +741,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
// Disabled - clear chord data
|
||||
pianoRollState.setCurrentSuitableChords([]);
|
||||
pianoRollState.setCurrentSuitableChordsPitchClasses({});
|
||||
pianoRollState.setCurrentHoveredChordGuideCandidate(null);
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Chord guide disabled - cleared suitable chords`);
|
||||
@@ -756,6 +757,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
// Update piano roll state
|
||||
pianoRollState.setCurrentSuitableChords(suitableChords);
|
||||
pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses);
|
||||
pianoRollState.setCurrentHoveredChordGuideCandidate(null);
|
||||
|
||||
if (DEBUG_MODE.PIANO_ROLL) {
|
||||
console.log(`Chord guide updated: ${chordGuide} (${functionType})`);
|
||||
|
||||
@@ -319,6 +319,28 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-chord-guide-reset-row {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.settings-chord-guide-toolbar {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-chord-guide-toolbar .event-list-toolbar-group:first-child {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-chord-guide-toolbar .settings-chord-guide-add-button {
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.settings-chord-guide-toolbar .settings-chord-guide-add-button:hover {
|
||||
border: 1px solid #5a5a5a;
|
||||
}
|
||||
|
||||
button.settings-help {
|
||||
color: #5a9fd4;
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ChordGuideSettings from './ChordGuideSettings';
|
||||
import chordGuideDataJson from '../../../../public/resources/modes/chord_guide.json';
|
||||
import type { ChordGuideData } from '../../../core/ChordGuideTypes';
|
||||
|
||||
const chordGuideData = chordGuideDataJson as ChordGuideData;
|
||||
|
||||
const showAlertMock = vi.fn();
|
||||
const configState = new Map<string, unknown>([
|
||||
['chord_guide.chord_definition', ''],
|
||||
['chord_guide.custom_items', null],
|
||||
]);
|
||||
|
||||
const configManagerMock = {
|
||||
@@ -22,38 +27,178 @@ vi.mock('../../../core/config/ConfigManager', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../util/scaleUtil', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../util/scaleUtil')>('../../../util/scaleUtil');
|
||||
return {
|
||||
...actual,
|
||||
validateFunctionalChordsJSON: vi.fn(() => ({ valid: true, errors: [] })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../util/dialogUtil', () => ({
|
||||
showAlert: vi.fn(),
|
||||
showAlert: (...args: unknown[]) => showAlertMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../../core/KGCore', () => ({
|
||||
KGCore: {
|
||||
ORIGINAL_FUNCTIONAL_CHORDS_DATA: {},
|
||||
FUNCTIONAL_CHORDS_DATA: {},
|
||||
CHORD_GUIDE_DATA: {
|
||||
ionian: { T: [], S: [], D: [] },
|
||||
aeolian: { T: [], S: [], D: [] },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ChordGuideSettings', () => {
|
||||
const getGroup = (heading: 'Major Candidate Chords' | 'Minor Candidate Chords') => (
|
||||
screen.getByText(heading).closest('.settings-group') as HTMLElement
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
configState.set('chord_guide.chord_definition', '');
|
||||
configState.set('chord_guide.custom_items', null);
|
||||
configManagerMock.get.mockClear();
|
||||
configManagerMock.set.mockClear();
|
||||
showAlertMock.mockClear();
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.includes('resources/modes/chord_guide.json')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => structuredClone(chordGuideData),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders the legacy notice for chord guide definitions', async () => {
|
||||
it('renders major and minor groups with base-key guidance', async () => {
|
||||
render(<ChordGuideSettings />);
|
||||
|
||||
expect(await screen.findByText('Legacy Chord Definition')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/no longer affects chord-guide suggestions in the piano roll/i)
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByText('Major Candidate Chords')).toBeTruthy();
|
||||
expect(screen.getByText('Minor Candidate Chords')).toBeTruthy();
|
||||
expect(screen.getByText(/relative to C major/i)).toBeTruthy();
|
||||
expect(screen.getByText(/relative to A minor/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switches T/S/D tabs as a mutex control', async () => {
|
||||
render(<ChordGuideSettings />);
|
||||
|
||||
await screen.findByText('Major Candidate Chords');
|
||||
const majorGroup = getGroup('Major Candidate Chords');
|
||||
|
||||
expect(within(majorGroup).getByTitle('C')).toBeTruthy();
|
||||
fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' }));
|
||||
|
||||
expect(within(majorGroup).getByTitle('Fmaj7')).toBeTruthy();
|
||||
expect(within(majorGroup).queryByTitle('Cmaj7')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds and deletes rows in the active table', async () => {
|
||||
render(<ChordGuideSettings />);
|
||||
|
||||
await screen.findByText('Major Candidate Chords');
|
||||
const addButtons = screen.getAllByTitle(/Add .* chord/);
|
||||
fireEvent.click(addButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledWith(
|
||||
'chord_guide.custom_items',
|
||||
expect.objectContaining({
|
||||
major: expect.objectContaining({
|
||||
T: expect.arrayContaining([expect.objectContaining({ name: 'C' })]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const rows = screen.getAllByRole('row');
|
||||
fireEvent.click(rows[1]);
|
||||
const deleteButtons = screen.getAllByTitle('Delete selected rows');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates notes and source after a valid chord edit', async () => {
|
||||
render(<ChordGuideSettings />);
|
||||
|
||||
await screen.findByText('Major Candidate Chords');
|
||||
const majorGroup = getGroup('Major Candidate Chords');
|
||||
fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' }));
|
||||
|
||||
const chordCell = within(majorGroup).getByTitle('F');
|
||||
fireEvent.doubleClick(chordCell);
|
||||
const input = screen.getByDisplayValue('F');
|
||||
fireEvent.change(input, { target: { value: 'D7' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
const saved = configManagerMock.set.mock.calls.at(-1)?.[1] as {
|
||||
major: { S: Array<{ name: string; notes: string[]; source: string }> };
|
||||
};
|
||||
expect(saved.major.S[0]).toMatchObject({
|
||||
name: 'D7',
|
||||
notes: ['D', 'F#', 'A', 'C'],
|
||||
source: 'Non-Diatonic',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid chord edits', async () => {
|
||||
render(<ChordGuideSettings />);
|
||||
|
||||
await screen.findByText('Major Candidate Chords');
|
||||
const chordCell = screen.getAllByTitle('C')[0];
|
||||
fireEvent.doubleClick(chordCell);
|
||||
const input = screen.getByDisplayValue('C');
|
||||
fireEvent.change(input, { target: { value: 'invalid' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showAlertMock).toHaveBeenCalledWith(expect.stringContaining('valid chord symbol'));
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces the 128-character note limit', async () => {
|
||||
render(<ChordGuideSettings />);
|
||||
|
||||
await screen.findByText('Major Candidate Chords');
|
||||
const noteCell = screen.getAllByRole('cell').find((cell) => cell.textContent === chordGuideData.ionian.T[0].note) as HTMLElement;
|
||||
fireEvent.doubleClick(noteCell);
|
||||
const input = screen.getByDisplayValue(chordGuideData.ionian.T[0].note);
|
||||
fireEvent.change(input, { target: { value: 'x'.repeat(200) } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
const saved = configManagerMock.set.mock.calls.at(-1)?.[1] as { major: { T: Array<{ note: string }> } };
|
||||
expect(saved.major.T[0].note).toHaveLength(128);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets back to bundled defaults', async () => {
|
||||
const customItems = structuredClone(chordGuideData);
|
||||
customItems.ionian.T = [{
|
||||
name: 'G',
|
||||
notes: ['G', 'B', 'D'],
|
||||
source: 'Diatonic',
|
||||
note: 'custom',
|
||||
}];
|
||||
configState.set('chord_guide.custom_items', {
|
||||
major: {
|
||||
T: customItems.ionian.T,
|
||||
S: customItems.ionian.S,
|
||||
D: customItems.ionian.D,
|
||||
},
|
||||
minor: {
|
||||
T: customItems.aeolian.T,
|
||||
S: customItems.aeolian.S,
|
||||
D: customItems.aeolian.D,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ChordGuideSettings />);
|
||||
|
||||
expect((await screen.findAllByTitle('G')).length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByText('Reset to Default'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledWith('chord_guide.custom_items', null);
|
||||
expect(screen.getAllByTitle('C')[0]).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,108 +1,415 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { FaPlus, FaTrash } from 'react-icons/fa';
|
||||
import '../../EventListPanel.css';
|
||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||
import { validateFunctionalChordsJSON } from '../../../util/scaleUtil';
|
||||
import { KGCore } from '../../../core/KGCore';
|
||||
import type {
|
||||
ChordGuideCustomConfig,
|
||||
ChordGuideData,
|
||||
ChordGuideGroupKey,
|
||||
ChordGuideItem,
|
||||
ChordGuideModeDefinition,
|
||||
} from '../../../core/ChordGuideTypes';
|
||||
import {
|
||||
buildChordGuideCustomConfigFromData,
|
||||
buildChordGuideDataFromDefaultsAndConfig,
|
||||
buildDerivedChordGuideItem,
|
||||
createDefaultChordForGroup,
|
||||
} from '../../../util/chordGuideConfigUtil';
|
||||
import { parseChordSymbol } from '../../../util/chordUtil';
|
||||
import { showAlert } from '../../../util/dialogUtil';
|
||||
import { isModifierKeyPressed } from '../../../util/osUtil';
|
||||
|
||||
type FunctionType = 'T' | 'S' | 'D';
|
||||
type EditableColumn = 'name' | 'note';
|
||||
|
||||
interface EditingCell {
|
||||
group: ChordGuideGroupKey;
|
||||
rowIndex: number;
|
||||
column: EditableColumn;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const FUNCTION_BUTTONS: Array<{ value: FunctionType; label: string; title: string }> = [
|
||||
{ value: 'T', label: 'T', title: 'Tonic' },
|
||||
{ value: 'S', label: 'S', title: 'Subdominant' },
|
||||
{ value: 'D', label: 'D', title: 'Dominant' },
|
||||
];
|
||||
|
||||
const GROUP_LABELS: Record<ChordGuideGroupKey, string> = {
|
||||
major: 'Major Candidate Chords',
|
||||
minor: 'Minor Candidate Chords',
|
||||
};
|
||||
|
||||
const GROUP_HELP: Record<ChordGuideGroupKey, string> = {
|
||||
major: 'Enter chords relative to C major. The app will transpose them automatically for other major key signatures.',
|
||||
minor: 'Enter chords relative to A minor. The app will transpose them automatically for other minor key signatures.',
|
||||
};
|
||||
|
||||
const CONFIG_KEY = 'chord_guide.custom_items';
|
||||
|
||||
function cloneModeDefinition(definition: ChordGuideModeDefinition): ChordGuideModeDefinition {
|
||||
return {
|
||||
T: definition.T.map((item) => ({ ...item, notes: [...item.notes] })),
|
||||
S: definition.S.map((item) => ({ ...item, notes: [...item.notes] })),
|
||||
D: definition.D.map((item) => ({ ...item, notes: [...item.notes] })),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneCustomConfig(config: ChordGuideCustomConfig): ChordGuideCustomConfig {
|
||||
return {
|
||||
major: cloneModeDefinition(config.major),
|
||||
minor: cloneModeDefinition(config.minor),
|
||||
};
|
||||
}
|
||||
|
||||
function getModeDefinition(config: ChordGuideCustomConfig, group: ChordGuideGroupKey): ChordGuideModeDefinition {
|
||||
return group === 'major' ? config.major : config.minor;
|
||||
}
|
||||
|
||||
function setModeDefinition(
|
||||
config: ChordGuideCustomConfig,
|
||||
group: ChordGuideGroupKey,
|
||||
definition: ChordGuideModeDefinition,
|
||||
): ChordGuideCustomConfig {
|
||||
return group === 'major'
|
||||
? { ...config, major: definition }
|
||||
: { ...config, minor: definition };
|
||||
}
|
||||
|
||||
async function loadBundledChordGuideData(): Promise<ChordGuideData> {
|
||||
const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/chord_guide.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch chord_guide.json: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<ChordGuideData>;
|
||||
}
|
||||
|
||||
const ChordGuideSettings: React.FC = () => {
|
||||
const [chordDefinition, setChordDefinition] = useState<string>('');
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
const [defaultsData, setDefaultsData] = React.useState<ChordGuideData | null>(null);
|
||||
const [customConfig, setCustomConfig] = React.useState<ChordGuideCustomConfig | null>(null);
|
||||
const [activeFunctions, setActiveFunctions] = React.useState<Record<ChordGuideGroupKey, FunctionType>>({
|
||||
major: 'T',
|
||||
minor: 'T',
|
||||
});
|
||||
const [selectedRows, setSelectedRows] = React.useState<Record<ChordGuideGroupKey, Set<number>>>({
|
||||
major: new Set(),
|
||||
minor: new Set(),
|
||||
});
|
||||
const [editingCell, setEditingCell] = React.useState<EditingCell | null>(null);
|
||||
const editInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const previousEditingCellKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
// Load configuration values on component mount
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
React.useEffect(() => {
|
||||
const initialize = async () => {
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
|
||||
setChordDefinition((configManager.get('chord_guide.chord_definition') as string) || '');
|
||||
const bundledData = await loadBundledChordGuideData();
|
||||
const persisted = configManager.get(CONFIG_KEY) as ChordGuideCustomConfig | null | undefined;
|
||||
setDefaultsData(bundledData);
|
||||
setCustomConfig(persisted ? cloneCustomConfig(persisted) : buildChordGuideCustomConfigFromData(bundledData));
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
void initialize().catch((error) => {
|
||||
console.error('Failed to initialize chord guide settings:', error);
|
||||
void showAlert('Failed to load chord guide settings.');
|
||||
});
|
||||
}, [configManager]);
|
||||
|
||||
// Debounced save function for textarea
|
||||
const debouncedSave = useCallback((value: string) => {
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
await configManager.set('chord_guide.chord_definition', value);
|
||||
console.log('Chord definition saved');
|
||||
} catch (error) {
|
||||
console.error('Failed to save chord definition:', error);
|
||||
React.useEffect(() => {
|
||||
if (!editingCell) {
|
||||
previousEditingCellKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const editingCellKey = `${editingCell.group}-${editingCell.rowIndex}-${editingCell.column}`;
|
||||
if (previousEditingCellKeyRef.current === editingCellKey) {
|
||||
return;
|
||||
}
|
||||
previousEditingCellKeyRef.current = editingCellKey;
|
||||
|
||||
editInputRef.current?.focus();
|
||||
editInputRef.current?.select();
|
||||
}, [editingCell]);
|
||||
|
||||
const persistCustomConfig = React.useCallback(async (nextConfig: ChordGuideCustomConfig, persistValue: ChordGuideCustomConfig | null = nextConfig) => {
|
||||
if (!defaultsData) {
|
||||
return;
|
||||
}
|
||||
const cloned = cloneCustomConfig(nextConfig);
|
||||
setCustomConfig(cloned);
|
||||
KGCore.CHORD_GUIDE_DATA = buildChordGuideDataFromDefaultsAndConfig(defaultsData, cloned);
|
||||
await configManager.set(CONFIG_KEY, persistValue ? cloneCustomConfig(persistValue) : null);
|
||||
}, [configManager, defaultsData]);
|
||||
|
||||
const updateGroupRows = React.useCallback(async (
|
||||
group: ChordGuideGroupKey,
|
||||
functionType: FunctionType,
|
||||
updater: (rows: ChordGuideItem[]) => ChordGuideItem[],
|
||||
) => {
|
||||
if (!customConfig) {
|
||||
return;
|
||||
}
|
||||
const nextConfig = cloneCustomConfig(customConfig);
|
||||
const modeDefinition = getModeDefinition(nextConfig, group);
|
||||
const nextDefinition: ChordGuideModeDefinition = {
|
||||
...modeDefinition,
|
||||
[functionType]: updater(modeDefinition[functionType]),
|
||||
};
|
||||
const updatedConfig = setModeDefinition(nextConfig, group, nextDefinition);
|
||||
await persistCustomConfig(updatedConfig);
|
||||
}, [customConfig, persistCustomConfig]);
|
||||
|
||||
const commitEditingCell = React.useCallback(async () => {
|
||||
if (!editingCell || !customConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { group, rowIndex, column } = editingCell;
|
||||
const functionType = activeFunctions[group];
|
||||
const modeDefinition = getModeDefinition(customConfig, group);
|
||||
const row = modeDefinition[functionType][rowIndex];
|
||||
if (!row) {
|
||||
setEditingCell(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedValue = editingCell.value.trim();
|
||||
if (column === 'name') {
|
||||
if (!trimmedValue || parseChordSymbol(trimmedValue) === null) {
|
||||
await showAlert('Please enter a valid chord symbol. Example: Bm7b5');
|
||||
return;
|
||||
}
|
||||
}, 1000); // 1 second debounce for longer text
|
||||
const derived = buildDerivedChordGuideItem(group, { name: trimmedValue, note: row.note });
|
||||
if (!derived) {
|
||||
await showAlert('Unable to derive notes for this chord. Please use a supported chord symbol.');
|
||||
return;
|
||||
}
|
||||
await updateGroupRows(group, functionType, (rows) => rows.map((candidate, index) => (
|
||||
index === rowIndex
|
||||
? { ...derived, roman: undefined }
|
||||
: candidate
|
||||
)));
|
||||
} else {
|
||||
await updateGroupRows(group, functionType, (rows) => rows.map((candidate, index) => (
|
||||
index === rowIndex
|
||||
? { ...candidate, note: trimmedValue.slice(0, 128) }
|
||||
: candidate
|
||||
)));
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [configManager]);
|
||||
setEditingCell(null);
|
||||
}, [activeFunctions, customConfig, editingCell, updateGroupRows]);
|
||||
|
||||
// Save configuration when value changes
|
||||
const handleChordDefinitionChange = (value: string) => {
|
||||
setChordDefinition(value);
|
||||
const handleEditInputBlur = () => {
|
||||
void commitEditingCell();
|
||||
};
|
||||
|
||||
// Validate the JSON
|
||||
if (value.trim()) {
|
||||
const validationResult = validateFunctionalChordsJSON(value);
|
||||
setValidationErrors(validationResult.valid ? [] : validationResult.errors);
|
||||
const handleEditInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void commitEditingCell();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setEditingCell(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Update FUNCTIONAL_CHORDS_DATA if valid and non-empty
|
||||
if (validationResult.valid) {
|
||||
try {
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = JSON.parse(value);
|
||||
console.log('Applied custom chord definition');
|
||||
} catch (error) {
|
||||
console.error('Failed to parse chord definition:', error);
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA;
|
||||
}
|
||||
const handleRowClick = (
|
||||
group: ChordGuideGroupKey,
|
||||
rowIndex: number,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
if (editingCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelected = new Set(selectedRows[group]);
|
||||
if (isModifierKeyPressed(event)) {
|
||||
if (nextSelected.has(rowIndex)) {
|
||||
nextSelected.delete(rowIndex);
|
||||
} else {
|
||||
// Revert to original if invalid
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA;
|
||||
console.log('Invalid chord definition, reverted to original');
|
||||
nextSelected.add(rowIndex);
|
||||
}
|
||||
} else {
|
||||
setValidationErrors([]);
|
||||
// Revert to original if empty
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA;
|
||||
console.log('Chord definition cleared, reverted to original');
|
||||
nextSelected.clear();
|
||||
nextSelected.add(rowIndex);
|
||||
}
|
||||
|
||||
debouncedSave(value);
|
||||
setSelectedRows((previous) => ({ ...previous, [group]: nextSelected }));
|
||||
};
|
||||
|
||||
// Load default template from functional_chords.json (preserving original formatting)
|
||||
const handleLoadDefaultTemplate = async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch functional_chords.json: ${response.status}`);
|
||||
}
|
||||
// Get the raw text to preserve original formatting
|
||||
const rawText = await response.text();
|
||||
setChordDefinition(rawText);
|
||||
setValidationErrors([]); // Clear errors when loading valid template
|
||||
await configManager.set('chord_guide.chord_definition', rawText);
|
||||
|
||||
// Revert to original if empty
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA;
|
||||
|
||||
console.log('Loaded default chord template');
|
||||
} catch (error) {
|
||||
console.error('Failed to load default template:', error);
|
||||
await showAlert('Failed to load default template. Please check the console for details.');
|
||||
const handleTableBackgroundMouseDown = (group: ChordGuideGroupKey, event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
setSelectedRows((previous) => ({ ...previous, [group]: new Set() }));
|
||||
};
|
||||
|
||||
// Clear the chord definition
|
||||
const handleClear = async () => {
|
||||
setChordDefinition('');
|
||||
setValidationErrors([]); // Clear errors when clearing
|
||||
await configManager.set('chord_guide.chord_definition', '');
|
||||
const handleAddRow = async (group: ChordGuideGroupKey) => {
|
||||
const functionType = activeFunctions[group];
|
||||
const defaultChord = createDefaultChordForGroup(group);
|
||||
await updateGroupRows(group, functionType, (rows) => [...rows, { ...defaultChord, roman: undefined }]);
|
||||
setSelectedRows((previous) => ({ ...previous, [group]: new Set([getModeDefinition(customConfig!, group)[functionType].length]) }));
|
||||
};
|
||||
|
||||
// Revert to original if empty
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA;
|
||||
|
||||
console.log('Chord definition cleared');
|
||||
const handleDeleteRows = async (group: ChordGuideGroupKey) => {
|
||||
const selection = selectedRows[group];
|
||||
if (selection.size === 0) {
|
||||
return;
|
||||
}
|
||||
const functionType = activeFunctions[group];
|
||||
await updateGroupRows(group, functionType, (rows) => rows.filter((_, index) => !selection.has(index)));
|
||||
setSelectedRows((previous) => ({ ...previous, [group]: new Set() }));
|
||||
};
|
||||
|
||||
const handleResetToDefault = async () => {
|
||||
if (!defaultsData) {
|
||||
return;
|
||||
}
|
||||
const resetConfig = buildChordGuideCustomConfigFromData(defaultsData);
|
||||
setEditingCell(null);
|
||||
setSelectedRows({ major: new Set(), minor: new Set() });
|
||||
setCustomConfig(resetConfig);
|
||||
KGCore.CHORD_GUIDE_DATA = buildChordGuideDataFromDefaultsAndConfig(defaultsData, resetConfig);
|
||||
await configManager.set(CONFIG_KEY, null);
|
||||
};
|
||||
|
||||
const renderGroup = (group: ChordGuideGroupKey) => {
|
||||
if (!customConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const functionType = activeFunctions[group];
|
||||
const rows = getModeDefinition(customConfig, group)[functionType];
|
||||
const selection = selectedRows[group];
|
||||
|
||||
return (
|
||||
<div className="settings-group" key={group}>
|
||||
<div className="settings-group-header">
|
||||
<h4>{GROUP_LABELS[group]}</h4>
|
||||
</div>
|
||||
<div className="settings-description">{GROUP_HELP[group]}</div>
|
||||
|
||||
<div className="event-list-tabs" role="tablist" aria-label={`${GROUP_LABELS[group]} functions`}>
|
||||
{FUNCTION_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.value}
|
||||
className={`event-list-tab${functionType === button.value ? ' active' : ''}`}
|
||||
type="button"
|
||||
title={button.title}
|
||||
onClick={() => {
|
||||
setActiveFunctions((previous) => ({ ...previous, [group]: button.value }));
|
||||
setSelectedRows((previous) => ({ ...previous, [group]: new Set() }));
|
||||
setEditingCell(null);
|
||||
}}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="event-list-toolbar settings-chord-guide-toolbar">
|
||||
<div className="event-list-toolbar-group">
|
||||
<button
|
||||
className="event-list-add-button settings-chord-guide-add-button"
|
||||
title={`Add ${GROUP_LABELS[group]} chord`}
|
||||
type="button"
|
||||
onClick={() => { void handleAddRow(group); }}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
</div>
|
||||
<div className="event-list-toolbar-group event-list-toolbar-group-right">
|
||||
<button
|
||||
className="event-list-delete-button"
|
||||
title="Delete selected rows"
|
||||
type="button"
|
||||
onClick={() => { void handleDeleteRows(group); }}
|
||||
disabled={selection.size === 0}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="event-list-table-shell" onMouseDown={(event) => handleTableBackgroundMouseDown(group, event)}>
|
||||
<table className="event-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chord</th>
|
||||
<th>Notes</th>
|
||||
<th>Source</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => {
|
||||
const isEditingName = editingCell?.group === group && editingCell.rowIndex === rowIndex && editingCell.column === 'name';
|
||||
const isEditingNote = editingCell?.group === group && editingCell.rowIndex === rowIndex && editingCell.column === 'note';
|
||||
return (
|
||||
<tr
|
||||
key={`${group}-${functionType}-${rowIndex}-${row.name}`}
|
||||
className={selection.has(rowIndex) ? 'selected' : ''}
|
||||
onClick={(event) => handleRowClick(group, rowIndex, event)}
|
||||
>
|
||||
<td
|
||||
title={row.name}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditingCell({ group, rowIndex, column: 'name', value: row.name });
|
||||
}}
|
||||
>
|
||||
{isEditingName ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="event-list-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={handleEditInputKeyDown}
|
||||
/>
|
||||
) : row.name}
|
||||
</td>
|
||||
<td title={row.notes.join(' ')}>{row.notes.join(' ')}</td>
|
||||
<td title={row.source}>{row.source}</td>
|
||||
<td
|
||||
title={row.note}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditingCell({ group, rowIndex, column: 'note', value: row.note });
|
||||
}}
|
||||
>
|
||||
{isEditingNote ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="event-list-cell-input"
|
||||
maxLength={128}
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value.slice(0, 128) })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={handleEditInputKeyDown}
|
||||
/>
|
||||
) : row.note}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -112,45 +419,13 @@ const ChordGuideSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="settings-section-content">
|
||||
<div className="settings-group">
|
||||
<h4>Legacy Chord Definition</h4>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginBottom: '8px' }}>
|
||||
This editor is kept for legacy mode and highlighting behavior. It no longer affects chord-guide suggestions in the piano roll.
|
||||
</div>
|
||||
<div className="settings-help-links">
|
||||
<button
|
||||
className="settings-help"
|
||||
onClick={handleLoadDefaultTemplate}
|
||||
>
|
||||
Load Default Template
|
||||
</button>
|
||||
<button
|
||||
className="settings-help"
|
||||
onClick={handleClear}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<textarea
|
||||
className="settings-textarea"
|
||||
placeholder="Please input your legacy chord definitions"
|
||||
rows={8}
|
||||
value={chordDefinition}
|
||||
onChange={(e) => handleChordDefinitionChange(e.target.value)}
|
||||
/>
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="settings-validation-errors">
|
||||
{validationErrors.map((error, index) => (
|
||||
<div key={index} className="settings-validation-error">
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-help-links settings-chord-guide-reset-row">
|
||||
<button className="settings-help" type="button" onClick={() => { void handleResetToDefault(); }}>
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
{renderGroup('major')}
|
||||
{renderGroup('minor')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user