diff --git a/src/components/piano-roll/PianoGridCandidateStatus.test.tsx b/src/components/piano-roll/PianoGridCandidateStatus.test.tsx
new file mode 100644
index 0000000..ea9747b
--- /dev/null
+++ b/src/components/piano-roll/PianoGridCandidateStatus.test.tsx
@@ -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(
+
{}}
+ 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}
+
+ );
+
+ 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();
+ });
+ });
+});
diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx
index 0ed991f..a8cf32e 100644
--- a/src/components/piano-roll/PianoRoll.tsx
+++ b/src/components/piano-roll/PianoRoll.tsx
@@ -741,6 +741,7 @@ const PianoRoll: React.FC
= ({
// 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 = ({
// 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})`);
diff --git a/src/components/settings/Settings.css b/src/components/settings/Settings.css
index 03c66ce..797e730 100644
--- a/src/components/settings/Settings.css
+++ b/src/components/settings/Settings.css
@@ -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;
diff --git a/src/components/settings/sections/ChordGuideSettings.test.tsx b/src/components/settings/sections/ChordGuideSettings.test.tsx
index 154b455..d53b92b 100644
--- a/src/components/settings/sections/ChordGuideSettings.test.tsx
+++ b/src/components/settings/sections/ChordGuideSettings.test.tsx
@@ -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([
- ['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('../../../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();
- 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+ });
});
});
diff --git a/src/components/settings/sections/ChordGuideSettings.tsx b/src/components/settings/sections/ChordGuideSettings.tsx
index 97761ff..9f7fe38 100644
--- a/src/components/settings/sections/ChordGuideSettings.tsx
+++ b/src/components/settings/sections/ChordGuideSettings.tsx
@@ -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 = {
+ major: 'Major Candidate Chords',
+ minor: 'Minor Candidate Chords',
+};
+
+const GROUP_HELP: Record = {
+ 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 {
+ 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;
+}
const ChordGuideSettings: React.FC = () => {
- const [chordDefinition, setChordDefinition] = useState('');
- const [validationErrors, setValidationErrors] = useState([]);
-
const configManager = ConfigManager.instance();
+ const [defaultsData, setDefaultsData] = React.useState(null);
+ const [customConfig, setCustomConfig] = React.useState(null);
+ const [activeFunctions, setActiveFunctions] = React.useState>({
+ major: 'T',
+ minor: 'T',
+ });
+ const [selectedRows, setSelectedRows] = React.useState>>({
+ major: new Set(),
+ minor: new Set(),
+ });
+ const [editingCell, setEditingCell] = React.useState(null);
+ const editInputRef = React.useRef(null);
+ const previousEditingCellKeyRef = React.useRef(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) => {
+ 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,
+ ) => {
+ 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) => {
+ 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 (
+
+
+
{GROUP_LABELS[group]}
+
+
{GROUP_HELP[group]}
+
+
+ {FUNCTION_BUTTONS.map((button) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
handleTableBackgroundMouseDown(group, event)}>
+
+
+
+ | Chord |
+ Notes |
+ Source |
+ Note |
+
+
+
+ {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 (
+ handleRowClick(group, rowIndex, event)}
+ >
+ | {
+ event.stopPropagation();
+ setEditingCell({ group, rowIndex, column: 'name', value: row.name });
+ }}
+ >
+ {isEditingName ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={handleEditInputKeyDown}
+ />
+ ) : row.name}
+ |
+ {row.notes.join(' ')} |
+ {row.source} |
+ {
+ event.stopPropagation();
+ setEditingCell({ group, rowIndex, column: 'note', value: row.note });
+ }}
+ >
+ {isEditingNote ? (
+ setEditingCell({ ...editingCell, value: event.target.value.slice(0, 128) })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={handleEditInputKeyDown}
+ />
+ ) : row.note}
+ |
+
+ );
+ })}
+
+
+
+
+ );
};
return (
@@ -112,45 +419,13 @@ const ChordGuideSettings: React.FC = () => {