feat: implement scale-of-fifth based key signature picker
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user