feat: implement scale-of-fifth based key signature picker

This commit is contained in:
Xiaohan-Tian
2026-05-23 13:36:59 -07:00
parent fb3c6cde70
commit 247c76237d
12 changed files with 854 additions and 30 deletions
+184
View File
@@ -0,0 +1,184 @@
.key-signature-popup {
min-width: 270px;
padding: 12px 12px 10px;
}
.key-signature-picker {
width: 252px;
height: 224px;
position: relative;
color: #e0e0e0;
font-family: Georgia, 'Times New Roman', serif;
}
.key-signature-picker-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin: 0;
color: #f0f0f0;
letter-spacing: 0.4px;
pointer-events: none;
font-weight: 600;
}
.key-signature-picker-title.major {
top: 2px;
font-size: 10px;
}
.key-signature-picker-title.minor {
top: 60px;
font-size: 9px;
color: #9fc89c;
}
.key-signature-picker-ring {
position: absolute;
inset: 32px 40px 30px;
border-radius: 50%;
background:
radial-gradient(circle at center, transparent 0 38%, rgba(33, 33, 33, 0.96) 38% 49%, transparent 49%),
conic-gradient(
from 0deg,
rgba(84, 84, 84, 0.28),
rgba(69, 69, 69, 0.5),
rgba(84, 84, 84, 0.28)
);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
pointer-events: none;
}
.key-signature-picker-slot {
position: absolute;
transform: translate(-50%, -50%);
}
.key-signature-picker-slot-outer,
.key-signature-picker-slot-inner,
.key-signature-picker-slot-count {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.key-signature-picker-slot-count {
font-size: 7px;
color: #b8b8b8;
white-space: nowrap;
pointer-events: none;
}
.key-signature-picker-item-stack {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
}
.key-signature-picker-paired-row {
display: inline-flex;
align-items: baseline;
gap: 0;
white-space: nowrap;
}
.key-signature-picker-key {
display: inline-flex;
align-items: center;
}
.key-signature-picker-divider {
color: #b8b8b8;
font-size: 9px;
line-height: 1;
padding: 0 1px;
}
.key-signature-picker-button-paired {
padding: 0 2px;
border-radius: 999px;
}
.key-signature-picker-button {
appearance: none;
-webkit-appearance: none;
border: none;
background: transparent;
background-color: transparent;
box-shadow: none;
padding: 0;
font: inherit;
cursor: pointer;
line-height: 1;
transition: transform 0.16s ease, opacity 0.16s ease, text-shadow 0.16s ease;
}
.key-signature-picker-button:hover {
background: transparent;
background-color: transparent;
transform: scale(1.05);
text-shadow: 0 0 10px currentColor;
}
.key-signature-picker-button:focus-visible {
outline: 2px solid #2d2d2d;
outline-offset: 4px;
border-radius: 6px;
}
.key-signature-picker-button:focus,
.key-signature-picker-button:active {
background: transparent;
background-color: transparent;
box-shadow: none;
}
.key-signature-picker-button.major {
color: #e08b94;
font-size: 13px;
}
.key-signature-picker-button.minor {
color: #8fba88;
font-size: 11px;
}
.key-signature-picker-button.selected {
text-decoration: underline;
text-underline-offset: 3px;
}
.key-signature-picker-label {
display: inline-flex;
align-items: flex-start;
}
.key-signature-picker-accidental {
font-size: 0.58em;
line-height: 1;
margin-left: 1px;
position: relative;
top: -0.15em;
}
@media (max-width: 900px) {
.key-signature-popup {
min-width: min(92vw, 270px);
padding: 12px 10px 10px;
}
.key-signature-picker {
width: min(82vw, 252px);
height: min(72vw, 224px);
}
.key-signature-picker-button.major {
font-size: 12px;
}
.key-signature-picker-button.minor {
font-size: 10px;
}
}
@@ -0,0 +1,80 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import KeySignaturePickerPopup from './KeySignaturePickerPopup';
vi.mock('vexflow', () => {
class MockRenderer {
static Backends = { SVG: 'svg' };
private readonly host: HTMLElement;
constructor(host: HTMLElement) {
this.host = host;
}
resize() {}
getContext() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.host.appendChild(svg);
return {};
}
}
class MockStave {
constructor() {}
addClef() {
return this;
}
addKeySignature() {
return this;
}
setContext() {
return this;
}
draw() {
return this;
}
}
return {
Renderer: MockRenderer,
Stave: MockStave,
};
});
describe('KeySignaturePickerPopup', () => {
it('selects major and minor key signatures', () => {
const onChange = vi.fn();
render(<KeySignaturePickerPopup value="C major" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Select G major' }));
fireEvent.click(screen.getByRole('button', { name: 'Select E minor' }));
expect(onChange).toHaveBeenNthCalledWith(1, 'G major');
expect(onChange).toHaveBeenNthCalledWith(2, 'E minor');
});
it('renders both exact options for shared enharmonic slots', () => {
render(<KeySignaturePickerPopup value="C major" onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Select Gb major or F# major' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Select Eb minor or D# minor' })).toBeInTheDocument();
});
it('toggles between paired enharmonic options from a single button', () => {
const onChange = vi.fn();
const { rerender } = render(<KeySignaturePickerPopup value="C major" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Select Gb major or F# major' }));
expect(onChange).toHaveBeenCalledWith('Gb major');
rerender(<KeySignaturePickerPopup value="Gb major" onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Select Gb major or F# major' }));
expect(onChange).toHaveBeenLastCalledWith('F# major');
});
});
+164
View File
@@ -0,0 +1,164 @@
import React from 'react';
import type { KeySignature } from '../core/KGProject';
import { buildKeySignatureCircleSlots } from './keySignaturePickerLayout';
import './KeySignaturePickerPopup.css';
interface KeySignaturePickerPopupProps {
value: KeySignature;
// Keep the callback typed to project key signatures without duplicating the union locally.
// eslint-disable-next-line no-unused-vars
onChange: (keySignature: KeySignature) => void;
}
const OUTER_RADIUS = 87;
const INNER_RADIUS = 55;
const COUNT_RADIUS = 69;
const CENTER_X = 126;
const CENTER_Y = 112;
function getPosition(angleDeg: number, radius: number) {
const angle = (angleDeg * Math.PI) / 180;
return {
left: CENTER_X + radius * Math.cos(angle),
top: CENTER_Y + radius * Math.sin(angle),
};
}
function splitAccidental(label: string): { base: string; accidental: string | null } {
if (label.includes('#')) {
return { base: label.replace('#', ''), accidental: '♯' };
}
if (label.includes('b')) {
return { base: label.replace('b', ''), accidental: '♭' };
}
return { base: label, accidental: null };
}
const KeyLabel: React.FC<{ label: string }> = ({ label }) => {
const { base, accidental } = splitAccidental(label);
return (
<span className="key-signature-picker-label">
<span>{base}</span>
{accidental && <sup className="key-signature-picker-accidental">{accidental}</sup>}
</span>
);
};
const CombinedKeyLabel: React.FC<{ labels: string[] }> = ({ labels }) => (
<span className="key-signature-picker-label">
{labels.map((label, index) => (
<React.Fragment key={label}>
<KeyLabel label={label} />
{index < labels.length - 1 && <span className="key-signature-picker-divider">/</span>}
</React.Fragment>
))}
</span>
);
const KeySignaturePickerPopup: React.FC<KeySignaturePickerPopupProps> = ({ value, onChange }) => {
const slots = React.useMemo(() => buildKeySignatureCircleSlots(), []);
const handlePairedSelection = React.useCallback((keySignatures: KeySignature[]) => {
if (keySignatures.length === 0) {
return;
}
const activeIndex = keySignatures.indexOf(value);
if (activeIndex >= 0) {
onChange(keySignatures[(activeIndex + 1) % keySignatures.length]);
return;
}
onChange(keySignatures[0]);
}, [onChange, value]);
return (
<div className="key-signature-popup">
<div className="key-signature-picker">
<div className="key-signature-picker-ring" aria-hidden="true" />
<h3 className="key-signature-picker-title major">Major</h3>
<h3 className="key-signature-picker-title minor">Minor</h3>
{slots.map((slot) => {
const outerPosition = getPosition(slot.angleDeg, OUTER_RADIUS);
const innerPosition = getPosition(slot.angleDeg, INNER_RADIUS);
const countPosition = getPosition(slot.angleDeg, COUNT_RADIUS);
return (
<React.Fragment key={slot.id}>
<div
className="key-signature-picker-slot key-signature-picker-slot-outer"
style={outerPosition}
>
{slot.outerItems.length > 1 ? (
<button
type="button"
className={`key-signature-picker-button key-signature-picker-button-paired major ${slot.outerItems.some(item => item.keySignature === value) ? 'selected' : ''}`.trim()}
onClick={() => handlePairedSelection(slot.outerItems.map(item => item.keySignature))}
aria-label={`Select ${slot.outerItems.map(item => item.keySignature).join(' or ')}`}
title={slot.outerItems.map(item => item.keySignature).join(' / ')}
>
<CombinedKeyLabel labels={slot.outerItems.map(item => item.label)} />
</button>
) : (
<div className="key-signature-picker-item-stack">
{slot.outerItems.map((item) => (
<div className="key-signature-picker-key" key={item.keySignature}>
<button
type="button"
className={`key-signature-picker-button major ${value === item.keySignature ? 'selected' : ''}`.trim()}
onClick={() => onChange(item.keySignature)}
aria-label={`Select ${item.keySignature}`}
>
<KeyLabel label={item.label} />
</button>
</div>
))}
</div>
)}
</div>
<div
className="key-signature-picker-slot key-signature-picker-slot-inner"
style={innerPosition}
>
{slot.innerItems.length > 1 ? (
<button
type="button"
className={`key-signature-picker-button key-signature-picker-button-paired minor ${slot.innerItems.some(item => item.keySignature === value) ? 'selected' : ''}`.trim()}
onClick={() => handlePairedSelection(slot.innerItems.map(item => item.keySignature))}
aria-label={`Select ${slot.innerItems.map(item => item.keySignature).join(' or ')}`}
title={slot.innerItems.map(item => item.keySignature).join(' / ')}
>
<CombinedKeyLabel labels={slot.innerItems.map(item => item.label)} />
</button>
) : (
<div className="key-signature-picker-item-stack">
{slot.innerItems.map((item) => (
<button
type="button"
key={item.keySignature}
className={`key-signature-picker-button minor ${value === item.keySignature ? 'selected' : ''}`.trim()}
onClick={() => onChange(item.keySignature)}
aria-label={`Select ${item.keySignature}`}
>
<KeyLabel label={item.label} />
</button>
))}
</div>
)}
</div>
<div
className="key-signature-picker-slot key-signature-picker-slot-count"
style={countPosition}
aria-hidden="true"
>
{slot.accidentalLabel}
</div>
</React.Fragment>
);
})}
</div>
</div>
);
};
export default KeySignaturePickerPopup;
+33 -5
View File
@@ -195,29 +195,57 @@
color: #fff; color: #fff;
} }
.current-key-signature { .toolbar button.current-key-signature {
appearance: none;
border: none;
background: transparent;
color: inherit;
display: inline-block;
width: auto;
height: auto;
min-height: 0;
margin: 0;
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: normal; font-weight: normal;
letter-spacing: 0.5px; letter-spacing: 0.5px;
line-height: 1;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
padding: 2px 4px; padding: 2px 4px;
border-radius: 2px; border-radius: 2px;
white-space: nowrap;
} }
.current-key-signature:hover { .toolbar button.current-key-signature:hover {
background-color: #4a4a4a; background-color: #4a4a4a;
color: #fff; color: #fff;
} }
.toolbar button.current-key-signature:focus-visible {
outline: 1px solid #e0e0e0;
outline-offset: 1px;
}
.export-dropdown .quant-dropdown { .export-dropdown .quant-dropdown {
width: 250px; width: 250px;
left: 0; left: 0;
} }
.key-signature-dropdown .quant-dropdown { .key-signature-popup-anchor {
width: 100px; display: inline-flex;
left: 0; }
.key-signature-popup-surface {
--floating-popup-bg: #2d2d2d;
--floating-popup-border: #555;
}
.key-signature-popup-panel {
overflow: visible;
}
.key-signature-popup-arrow {
left: 50%;
} }
/* Clickable zoom styling */ /* Clickable zoom styling */
+82 -2
View File
@@ -62,6 +62,7 @@ const storeState = {
}; };
type StoreState = typeof storeState; type StoreState = typeof storeState;
// eslint-disable-next-line no-unused-vars
type StoreSelector = (state: StoreState) => unknown; type StoreSelector = (state: StoreState) => unknown;
vi.mock('../stores/projectStore', () => ({ vi.mock('../stores/projectStore', () => ({
@@ -83,7 +84,16 @@ vi.mock('../constants/coreConstants', async (importOriginal) => {
const actual = await importOriginal<typeof import('../constants/coreConstants')>(); const actual = await importOriginal<typeof import('../constants/coreConstants')>();
return { return {
...actual, ...actual,
KEY_SIGNATURE_MAP: { ...actual.KEY_SIGNATURE_MAP, 'C major': [] }, KEY_SIGNATURE_MAP: {
...actual.KEY_SIGNATURE_MAP,
'C major': actual.KEY_SIGNATURE_MAP['C major'],
'G major': actual.KEY_SIGNATURE_MAP['G major'],
'E minor': actual.KEY_SIGNATURE_MAP['E minor'],
'Gb major': actual.KEY_SIGNATURE_MAP['Gb major'],
'F# major': actual.KEY_SIGNATURE_MAP['F# major'],
'Eb minor': actual.KEY_SIGNATURE_MAP['Eb minor'],
'D# minor': actual.KEY_SIGNATURE_MAP['D# minor'],
},
}; };
}); });
@@ -125,7 +135,6 @@ vi.mock('../util/midiUtil', () => ({
convertMidiToProject: vi.fn(), convertMidiToProject: vi.fn(),
})); }));
vi.mock('../core/audio-interface/KGOfflineRenderer', () => ({ KGOfflineRenderer: { instance: vi.fn(() => ({})) } })); vi.mock('../core/audio-interface/KGOfflineRenderer', () => ({ KGOfflineRenderer: { instance: vi.fn(() => ({})) } }));
vi.mock('./common/KGDropdown', () => ({ default: () => null }));
vi.mock('./common/FileImportModal', () => ({ default: () => null })); vi.mock('./common/FileImportModal', () => ({ default: () => null }));
vi.mock('./common/LoadingOverlay', () => ({ default: () => null })); vi.mock('./common/LoadingOverlay', () => ({ default: () => null }));
vi.mock('./common/OpenProjectModal', () => ({ default: () => null })); vi.mock('./common/OpenProjectModal', () => ({ default: () => null }));
@@ -140,6 +149,49 @@ vi.mock('../util/dialogUtil', () => ({
showPrompt: vi.fn(), showPrompt: vi.fn(),
showTimeSigPrompt: vi.fn(), showTimeSigPrompt: vi.fn(),
})); }));
vi.mock('vexflow', () => {
class MockRenderer {
static Backends = { SVG: 'svg' };
private readonly host: HTMLElement;
constructor(host: HTMLElement) {
this.host = host;
}
resize() {}
getContext() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.host.appendChild(svg);
return {};
}
}
class MockStave {
constructor() {}
addClef() {
return this;
}
addKeySignature() {
return this;
}
setContext() {
return this;
}
draw() {
return this;
}
}
return {
Renderer: MockRenderer,
Stave: MockStave,
};
});
describe('Toolbar settings side-panel behavior', () => { describe('Toolbar settings side-panel behavior', () => {
beforeEach(() => { beforeEach(() => {
@@ -154,6 +206,8 @@ describe('Toolbar settings side-panel behavior', () => {
storeState.showChatBox = true; storeState.showChatBox = true;
storeState.showKGOnePanel = true; storeState.showKGOnePanel = true;
storeState.showEventListPanel = false; storeState.showEventListPanel = false;
storeState.keySignature = 'C major';
storeState.setKeySignature.mockClear();
}); });
it('suppresses active styling for side-panel buttons while Settings is visible', () => { it('suppresses active styling for side-panel buttons while Settings is visible', () => {
@@ -193,4 +247,30 @@ describe('Toolbar settings side-panel behavior', () => {
expect(storeState.setStatus).toHaveBeenCalledWith('Split 1 note at beat 12.00'); expect(storeState.setStatus).toHaveBeenCalledWith('Split 1 note at beat 12.00');
}); });
}); });
it('opens and closes the key signature popup from the toolbar trigger', () => {
render(<Toolbar />);
fireEvent.click(screen.getByRole('button', { name: /choose key signature/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Major')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('updates the store when a major or minor key is selected', () => {
render(<Toolbar />);
fireEvent.click(screen.getByRole('button', { name: /choose key signature/i }));
fireEvent.click(screen.getByRole('button', { name: 'Select G major' }));
expect(storeState.setKeySignature).toHaveBeenCalledWith('G major');
expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to G major');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /choose key signature/i }));
fireEvent.click(screen.getByRole('button', { name: 'Select E minor' }));
expect(storeState.setKeySignature).toHaveBeenCalledWith('E minor');
expect(storeState.setStatus).toHaveBeenCalledWith('Key signature changed to E minor');
});
}); });
+23 -21
View File
@@ -24,12 +24,13 @@ import { KGMainContentState } from '../core/state/KGMainContentState';
import { regionDeleteManager } from '../util/regionDeleteUtil'; import { regionDeleteManager } from '../util/regionDeleteUtil';
import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil'; import { handleCopyOperation, handlePasteOperation } from '../util/copyPasteUtil';
import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil'; import { convertProjectToMidi, convertMidiToProject } from '../util/midiUtil';
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
import { KGOfflineRenderer } from '../core/audio-interface/KGOfflineRenderer'; import { KGOfflineRenderer } from '../core/audio-interface/KGOfflineRenderer';
import KGDropdown from './common/KGDropdown'; import KGDropdown from './common/KGDropdown';
import FloatingPopup from './common/FloatingPopup';
import FileImportModal from './common/FileImportModal'; import FileImportModal from './common/FileImportModal';
import LoadingOverlay from './common/LoadingOverlay'; import LoadingOverlay from './common/LoadingOverlay';
import OpenProjectModal from './common/OpenProjectModal'; import OpenProjectModal from './common/OpenProjectModal';
import KeySignaturePickerPopup from './KeySignaturePickerPopup';
import { clearChatHistoryAndUI } from '../util/chatUtil'; import { clearChatHistoryAndUI } from '../util/chatUtil';
import PianoIcon from './common/icons/PianoIcon'; import PianoIcon from './common/icons/PianoIcon';
import MetronomeIcon from './common/icons/MetronomeIcon'; import MetronomeIcon from './common/icons/MetronomeIcon';
@@ -91,9 +92,6 @@ const Toolbar: React.FC = () => {
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showZoomSlider]); }, [showZoomSlider]);
// Key signature options
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
// Export options // Export options
const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"]; const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"];
const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null; const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
@@ -280,7 +278,7 @@ const Toolbar: React.FC = () => {
} }
}; };
const handleConfirmOpenProject = async (_projectNameToLoad: string) => { const handleConfirmOpenProject = async () => {
const confirmed = await showConfirm( const confirmed = await showConfirm(
'Open this project? Any unsaved changes in the current project will be lost.' 'Open this project? Any unsaved changes in the current project will be lost.'
); );
@@ -1154,25 +1152,29 @@ const Toolbar: React.FC = () => {
<span className='current-time-signature' onClick={handleTimeSignatureClick} style={{ cursor: 'pointer' }}>{timeSignature.numerator + "/" + timeSignature.denominator}</span> <span className='current-time-signature' onClick={handleTimeSignatureClick} style={{ cursor: 'pointer' }}>{timeSignature.numerator + "/" + timeSignature.denominator}</span>
</div> </div>
<div className="transport-item" style={{ position: 'relative' }}> <div className="transport-item" style={{ position: 'relative' }}>
<span <FloatingPopup
isOpen={showKeySignatureDropdown}
onClose={() => setShowKeySignatureDropdown(false)}
placement="bottom"
className="key-signature-popup-anchor"
contentClassName="key-signature-popup-surface"
panelClassName="key-signature-popup-panel"
arrowClassName="key-signature-popup-arrow"
trigger={(
<button
type="button"
className='current-key-signature' className='current-key-signature'
onClick={() => setShowKeySignatureDropdown(!showKeySignatureDropdown)} onClick={() => setShowKeySignatureDropdown((current) => !current)}
style={{ cursor: 'pointer' }} aria-haspopup="dialog"
aria-expanded={showKeySignatureDropdown}
aria-label={`Choose key signature, current ${keySignature}`}
> >
{keySignature} {keySignature}
</span> </button>
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}> )}
<KGDropdown >
options={keySignatureOptions} <KeySignaturePickerPopup value={keySignature} onChange={handleKeySignatureChange} />
value={keySignature} </FloatingPopup>
onChange={handleKeySignatureChange}
label="Key Signature"
hideButton={true}
isOpen={showKeySignatureDropdown}
onToggle={setShowKeySignatureDropdown}
className="key-signature-dropdown"
/>
</div>
</div> </div>
</div> </div>
<button <button
+57
View File
@@ -0,0 +1,57 @@
.floating-popup {
position: relative;
display: inline-flex;
align-items: center;
}
.floating-popup-trigger {
display: inline-flex;
align-items: center;
}
.floating-popup-surface {
--floating-popup-bg: #2d2d2d;
--floating-popup-border: #4a4a4a;
--floating-popup-shadow: 0 18px 36px rgba(0, 0, 0, 0.28);
position: absolute;
z-index: 10000;
}
.floating-popup-surface[data-placement='bottom'] {
top: calc(100% + 14px);
left: 50%;
transform: translateX(-50%);
}
.floating-popup-panel {
position: relative;
background: var(--floating-popup-bg);
border: 1px solid var(--floating-popup-border);
border-radius: 28px;
box-shadow: var(--floating-popup-shadow);
}
.floating-popup-panel::before {
content: '';
position: absolute;
top: -1px;
left: calc(50% - 12px);
width: 24px;
height: 1px;
background: var(--floating-popup-bg);
}
.floating-popup-arrow {
position: absolute;
width: 20px;
height: 20px;
background: var(--floating-popup-bg);
transform: translateX(-50%) rotate(45deg);
}
.floating-popup-arrow[data-placement='bottom'] {
top: -9px;
left: 50%;
border-top: 1px solid var(--floating-popup-border);
border-left: 1px solid var(--floating-popup-border);
}
@@ -0,0 +1,29 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import FloatingPopup from './FloatingPopup';
describe('FloatingPopup', () => {
it('closes on outside click and escape', () => {
const onClose = vi.fn();
render(
<div>
<FloatingPopup
isOpen={true}
onClose={onClose}
trigger={<button type="button">Toggle</button>}
>
<div>Popup body</div>
</FloatingPopup>
<button type="button">Outside</button>
</div>
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
fireEvent.mouseDown(screen.getByRole('button', { name: 'Outside' }));
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(2);
});
});
+85
View File
@@ -0,0 +1,85 @@
import React from 'react';
import './FloatingPopup.css';
type FloatingPopupPlacement = 'bottom' | 'top' | 'left' | 'right';
interface FloatingPopupProps {
isOpen: boolean;
onClose: () => void;
trigger: React.ReactNode;
children: React.ReactNode;
placement?: FloatingPopupPlacement;
className?: string;
triggerClassName?: string;
contentClassName?: string;
panelClassName?: string;
arrowClassName?: string;
}
const FloatingPopup: React.FC<FloatingPopupProps> = ({
isOpen,
onClose,
trigger,
children,
placement = 'bottom',
className = '',
triggerClassName = '',
contentClassName = '',
panelClassName = '',
arrowClassName = '',
}) => {
const rootRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (!isOpen) {
return;
}
const handleMouseDown = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
return (
<div className={`floating-popup ${className}`.trim()} ref={rootRef}>
<div className={`floating-popup-trigger ${triggerClassName}`.trim()}>
{trigger}
</div>
{isOpen && (
<div
className={`floating-popup-surface ${contentClassName}`.trim()}
data-placement={placement}
role="dialog"
aria-modal="false"
>
<div
className={`floating-popup-arrow ${arrowClassName}`.trim()}
data-placement={placement}
aria-hidden="true"
/>
<div className={`floating-popup-panel ${panelClassName}`.trim()}>
{children}
</div>
</div>
)}
</div>
);
};
export default FloatingPopup;
+1
View File
@@ -5,5 +5,6 @@ export { default as LoadingOverlay } from './LoadingOverlay';
export { default as OpenProjectModal } from './OpenProjectModal'; export { default as OpenProjectModal } from './OpenProjectModal';
export { default as DialogProvider } from './DialogProvider'; export { default as DialogProvider } from './DialogProvider';
export { default as TrackCreateDialog } from './TrackCreateDialog'; export { default as TrackCreateDialog } from './TrackCreateDialog';
export { default as FloatingPopup } from './FloatingPopup';
export { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil'; export { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil';
export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil'; export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { buildKeySignatureCircleSlots } from './keySignaturePickerLayout';
describe('keySignaturePickerLayout', () => {
it('builds shared enharmonic slots with both exact key signatures', () => {
const slots = buildKeySignatureCircleSlots();
const sharedMajorSlot = slots.find(slot => slot.outerItems.some(item => item.keySignature === 'Gb major'));
const sharedMinorSlot = slots.find(slot => slot.innerItems.some(item => item.keySignature === 'D# minor'));
expect(sharedMajorSlot?.outerItems.map(item => item.keySignature)).toEqual(['Gb major', 'F# major']);
expect(sharedMinorSlot?.innerItems.map(item => item.keySignature)).toEqual(['Eb minor', 'D# minor']);
});
it('uses accidental counts derived from the key signature map', () => {
const slots = buildKeySignatureCircleSlots();
const cSlot = slots.find(slot => slot.id === 'c');
const gbSlot = slots.find(slot => slot.id === 'gb-fsharp');
expect(cSlot?.accidentalLabel).toBe('natural');
expect(gbSlot?.accidentalLabel).toBe('6♭ / 6♯');
});
});
@@ -0,0 +1,92 @@
import { KEY_SIGNATURE_MAP } from '../constants/coreConstants';
import type { KeySignature } from '../core/KGProject';
type KeyQuality = 'major' | 'minor';
interface KeySignatureSlotDefinition {
id: string;
majorKeys: KeySignature[];
minorKeys: KeySignature[];
}
export interface KeySignatureCircleItem {
keySignature: KeySignature;
label: string;
quality: KeyQuality;
}
export interface KeySignatureCircleSlot {
id: string;
angleDeg: number;
outerItems: KeySignatureCircleItem[];
innerItems: KeySignatureCircleItem[];
accidentalLabel: string;
}
const CIRCLE_SLOT_DEFINITIONS: KeySignatureSlotDefinition[] = [
{ id: 'c', majorKeys: ['C major'], minorKeys: ['A minor'] },
{ id: 'g', majorKeys: ['G major'], minorKeys: ['E minor'] },
{ id: 'd', majorKeys: ['D major'], minorKeys: ['B minor'] },
{ id: 'a', majorKeys: ['A major'], minorKeys: ['F# minor'] },
{ id: 'e', majorKeys: ['E major'], minorKeys: ['C# minor'] },
{ id: 'b-cb', majorKeys: ['B major', 'Cb major'], minorKeys: ['G# minor', 'Ab minor'] },
{ id: 'gb-fsharp', majorKeys: ['Gb major', 'F# major'], minorKeys: ['Eb minor', 'D# minor'] },
{ id: 'db-csharp', majorKeys: ['Db major', 'C# major'], minorKeys: ['Bb minor', 'A# minor'] },
{ id: 'ab', majorKeys: ['Ab major'], minorKeys: ['F minor'] },
{ id: 'eb', majorKeys: ['Eb major'], minorKeys: ['C minor'] },
{ id: 'bb', majorKeys: ['Bb major'], minorKeys: ['G minor'] },
{ id: 'f', majorKeys: ['F major'], minorKeys: ['D minor'] },
];
function assertKnownKeySignature(keySignature: KeySignature): KeySignature {
if (!(keySignature in KEY_SIGNATURE_MAP)) {
throw new Error(`Unknown key signature in circle layout: ${keySignature}`);
}
return keySignature;
}
function getLabelForKeySignature(keySignature: KeySignature): string {
const [tonic, quality] = keySignature.split(' ');
return quality === 'major' ? tonic : tonic.toLowerCase();
}
function formatAccidentalCount(keySignatures: KeySignature[]): string {
const segments = keySignatures.map((keySignature) => {
const entry = KEY_SIGNATURE_MAP[keySignature];
if (entry.sharps > 0) {
return `${entry.sharps}`;
}
if (entry.flats > 0) {
return `${entry.flats}`;
}
return 'natural';
});
return [...new Set(segments)].join(' / ');
}
export function buildKeySignatureCircleSlots(): KeySignatureCircleSlot[] {
const totalSlots = CIRCLE_SLOT_DEFINITIONS.length;
return CIRCLE_SLOT_DEFINITIONS.map((slot, index) => {
const outerItems = slot.majorKeys.map((keySignature) => ({
keySignature: assertKnownKeySignature(keySignature),
label: getLabelForKeySignature(keySignature),
quality: 'major' as const,
}));
const innerItems = slot.minorKeys.map((keySignature) => ({
keySignature: assertKnownKeySignature(keySignature),
label: getLabelForKeySignature(keySignature),
quality: 'minor' as const,
}));
return {
id: slot.id,
angleDeg: -90 + (360 / totalSlots) * index,
outerItems,
innerItems,
accidentalLabel: formatAccidentalCount(slot.majorKeys),
};
});
}