feat: implement global chord track

This commit is contained in:
Xiaohan-Tian
2026-05-24 23:06:08 -07:00
parent 77fb0e4c1f
commit 1879df8033
30 changed files with 2757 additions and 20 deletions
+323 -2
View File
@@ -1,12 +1,12 @@
{
"name": "K.G.Studio",
"version": "0.17.3-build.20260515",
"version": "0.17.4-build.20260520",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "K.G.Studio",
"version": "0.17.3-build.20260515",
"version": "0.17.4-build.20260520",
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"class-transformer": "^0.5.1",
@@ -25,6 +25,7 @@
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tonal": "^6.4.3",
"tone": "^15.1.22",
"vexflow": "^5.0.0",
"zustand": "^5.0.6"
@@ -2562,6 +2563,295 @@
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tonaljs/abc-notation": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz",
"integrity": "sha512-2fDUdPsFDdgZgyIiZCTYGKy30QiwIQxSXCSN2thGrSMXbQKCp8iTC8haStYcrA+25MPWhWlmvC/pz3tGcTNAqQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/array": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/@tonaljs/array/-/array-4.8.4.tgz",
"integrity": "sha512-97HVdpZy82PqNBDMM9PRSbO2DUrnxNg3++N4xqLpfby70fKHAHhTWrMXWZK+Dzs76HDPQLd+qhd4cq28eBZzjw==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/chord": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@tonaljs/chord/-/chord-6.1.2.tgz",
"integrity": "sha512-39crsPVyBJxdGJ1DGl+4diM+6+mZ/ws6crkQMlqX4zTYHMUVfJOQrko4mPF1CD89AFhHJe1zCT9Js+nNp3Gffg==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord-detect": "4.9.1",
"@tonaljs/chord-type": "5.1.1",
"@tonaljs/collection": "4.9.0",
"@tonaljs/interval": "^5.1.0",
"@tonaljs/pcset": "4.10.1",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/scale-type": "4.9.2"
}
},
"node_modules/@tonaljs/chord-detect": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@tonaljs/chord-detect/-/chord-detect-4.9.1.tgz",
"integrity": "sha512-rV/9+R7aZ9cQorQ3jdNMMMh63onosglYZM71Q0n7KKcWMAGrxF66MzxBG82xy+w1QDMJQslB3iHfDHUiS6wRjA==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord-type": "5.1.1",
"@tonaljs/pcset": "4.10.1",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/chord-type": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-5.1.1.tgz",
"integrity": "sha512-ti4WzRYvvjH7to0G3zlJFq7WsjHqmcqbk8Jv98aZSR5YumLdY/ua2yOPPyoPq82n6vfgjZsacnAZ3v8/SodOcw==",
"license": "MIT",
"dependencies": {
"@tonaljs/pcset": "4.10.1"
}
},
"node_modules/@tonaljs/collection": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@tonaljs/collection/-/collection-4.9.0.tgz",
"integrity": "sha512-Mk0h7O54nT6PgNVcUYauzxa5KOB23+0AOKudWzRH7JhJIN9vhVIC7PtwZXE+/G051UTbHSFIcN/afkgF4nB/8A==",
"license": "MIT"
},
"node_modules/@tonaljs/core": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/core/-/core-5.0.2.tgz",
"integrity": "sha512-v/fIRsB+lz93/yJnDEWvjDX6nDJ/Pz3cb0znm/UD8qKpN9BhN+v93iIaZG5LX0J2JNS35TtgIhwKFO10j6zieQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/duration-value": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@tonaljs/duration-value/-/duration-value-4.9.0.tgz",
"integrity": "sha512-Muz54HyIe0nMYKWx6wyTa4y17ma29DtpJF4/oqJphy6A124rAVDe/SKit8JGOvDYAQj71FUXqs17sXBxO/ExVw==",
"license": "MIT"
},
"node_modules/@tonaljs/interval": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/interval/-/interval-5.1.0.tgz",
"integrity": "sha512-GR9dUjn0j7yhjwjRh8HQxZYXOiVl05WfY3AFyMB9rfg807K4dSJmWfPTULPQXyHJ6NiZOPXcwRs8MxMLDxgdbg==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "^5.0.2",
"@tonaljs/pitch-distance": "^5.0.4",
"@tonaljs/pitch-interval": "^6.0.0"
}
},
"node_modules/@tonaljs/key": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@tonaljs/key/-/key-4.11.2.tgz",
"integrity": "sha512-fc1y5+NwS4S3amirzYLLB324PxJDO00oIBbLJclAddc46UBeYnu4FNz+1nZboHCXRQ/06/ftuaWD/l7HrAoulw==",
"license": "MIT",
"dependencies": {
"@tonaljs/note": "4.12.1",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/roman-numeral": "4.9.1"
}
},
"node_modules/@tonaljs/midi": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/@tonaljs/midi/-/midi-4.10.2.tgz",
"integrity": "sha512-MPamXhEwPL7L1udLfYMm3Ft8mLYtHr62Zi2w5zYHM2P7YwIvNoiX0+dvAN5is1Wvq4iVRa8AjFHerjOW/SZhGg==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/mode": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@tonaljs/mode/-/mode-4.9.2.tgz",
"integrity": "sha512-Il48fWX9SnGMzwNFVbizkWkr/SJ+aCIIHhjWfSf1agrQAoq81536qBXEyEyvia6aqEXC4OaXAQ5rSoBcmQRj1Q==",
"license": "MIT",
"dependencies": {
"@tonaljs/collection": "4.9.0",
"@tonaljs/interval": "5.1.0",
"@tonaljs/pcset": "4.10.1",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/scale-type": "4.9.2"
}
},
"node_modules/@tonaljs/note": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@tonaljs/note/-/note-4.12.1.tgz",
"integrity": "sha512-yh6cnhu21bUb0VuIQWxObSbWBVp2cHIkJj4zZ4qsNOW4jSqn74+wHpSbOTDF8q+2hl6upz7TtBRavtqwPtLUCQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/midi": "4.10.2",
"@tonaljs/pitch": "5.0.2",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/pcset": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@tonaljs/pcset/-/pcset-4.10.1.tgz",
"integrity": "sha512-CZG1rpKc38yMfpEJsbDTvsTmQqsek9xxcuMgK64Tr6sP1lEiDuZJbQeKVWWRnreBF2FQ1cEGLmfOpvSO+40csA==",
"license": "MIT",
"dependencies": {
"@tonaljs/collection": "4.9.0",
"@tonaljs/pitch": "5.0.2",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/pitch": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
"integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
"license": "MIT"
},
"node_modules/@tonaljs/pitch-distance": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-distance/-/pitch-distance-5.0.5.tgz",
"integrity": "sha512-dTfjsU0zyrj5YmiFio5prPaD5w7sBmHp4nnmlEg70nHY+SerAH0KiO9HM9usttVgRFUaXl0Gc7OI8YMGfSFmug==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/pitch-interval": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
"integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@tonaljs/pitch-note": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@tonaljs/pitch-note/-/pitch-note-6.1.0.tgz",
"integrity": "sha512-A4OSLo8DjM38u73862LnDmL4YInDDRBmg0fojXcvu4cyU3oOlqndyeHOra1OVoH/WW46uNIxNs1wJDZNPWL5KQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2"
}
},
"node_modules/@tonaljs/progression": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@tonaljs/progression/-/progression-4.9.2.tgz",
"integrity": "sha512-1Pmau5tKoWmY2H/fp9WVJ5Qm4c+xlu7IEZb3R3uLbc26kJq97DbuSnfArIAQoZTum2V6lOEN/Dt34E9OrGKnoA==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord": "6.1.2",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/roman-numeral": "4.9.1"
}
},
"node_modules/@tonaljs/range": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@tonaljs/range/-/range-4.9.2.tgz",
"integrity": "sha512-XhFbCJCrEEIVz3MNmdVp9QUyiJA6eqnNnQeNqto8AuT5BYuffHDoMkBmvvjPuV5Lh8zfF9D0aqglvqPbZ+neKQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/collection": "4.9.0",
"@tonaljs/midi": "4.10.2"
}
},
"node_modules/@tonaljs/rhythm-pattern": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@tonaljs/rhythm-pattern/-/rhythm-pattern-1.0.0.tgz",
"integrity": "sha512-3hp4Yw49e6BTWApRNb7arJ6HiICsHRFZYijuPE+vR93lIo9+lxcOMh0OPc4DyupTN7cvFTSB70Z7B/IeEJxhCQ==",
"license": "MIT"
},
"node_modules/@tonaljs/roman-numeral": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@tonaljs/roman-numeral/-/roman-numeral-4.9.1.tgz",
"integrity": "sha512-dJGKBNHdPrNTE97ZDk4t6wpNmgYcpHyLkAvWkRP9I/HsDkeTFFQqNXosYIvspPWrFySlRpeqqFwcqV74MYoOag==",
"license": "MIT",
"dependencies": {
"@tonaljs/pitch": "5.0.2",
"@tonaljs/pitch-interval": "6.1.0",
"@tonaljs/pitch-note": "6.1.0"
}
},
"node_modules/@tonaljs/scale": {
"version": "4.13.4",
"resolved": "https://registry.npmjs.org/@tonaljs/scale/-/scale-4.13.4.tgz",
"integrity": "sha512-hyilnmsCmoGQiRhYWKM1QPrqaNH3KGEfrSfxeXcicPnEh12yvrVRm4djKmTVw7Rc/5P4GniPAA51+CS0C5dbLw==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord-type": "5.1.1",
"@tonaljs/collection": "4.9.0",
"@tonaljs/note": "4.12.1",
"@tonaljs/pcset": "4.10.1",
"@tonaljs/pitch-distance": "5.0.5",
"@tonaljs/pitch-note": "6.1.0",
"@tonaljs/scale-type": "4.9.2"
}
},
"node_modules/@tonaljs/scale-type": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/@tonaljs/scale-type/-/scale-type-4.9.2.tgz",
"integrity": "sha512-XDRPySg0X6owJ6AVzVRpS4ZpgleAUQakuq5VtfQ0JitnJKGRYo5Y0w7Fl/wEz/X9aEMk2lxcdF8VGGMIsQFo0g==",
"license": "MIT",
"dependencies": {
"@tonaljs/pcset": "4.10.1"
}
},
"node_modules/@tonaljs/time-signature": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@tonaljs/time-signature/-/time-signature-4.9.0.tgz",
"integrity": "sha512-zRo8CBqg/2guzTlF2vxyVIuB5gmwWQaxlknJPUSDII8CTdQ/x3a1LlNoMKkvTUnqwCihe3WrNPZ5XUfOOTthYA==",
"license": "MIT"
},
"node_modules/@tonaljs/voice-leading": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@tonaljs/voice-leading/-/voice-leading-5.1.2.tgz",
"integrity": "sha512-lV5hsDZC4ekhh465d2aVFFqyG9AOZvwKdxU1+dNB/P6ldefU1yHC10mKdY7FrdhVgKeGZG1MbJh8rETaKVg7vA==",
"license": "MIT",
"dependencies": {
"@tonaljs/note": "4.12.1"
}
},
"node_modules/@tonaljs/voicing": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@tonaljs/voicing/-/voicing-5.1.3.tgz",
"integrity": "sha512-OLNcaIA086/4gwoXVa736yW0yP9TADi95/5LAS8KxMtzYBWPYzxyC1IxcE9bGqXsdBnI99au57J8+G6wroluyg==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord": "6.1.2",
"@tonaljs/interval": "5.1.0",
"@tonaljs/note": "4.12.1",
"@tonaljs/range": "4.9.2",
"@tonaljs/voice-leading": "5.1.2",
"@tonaljs/voicing-dictionary": "5.1.3"
}
},
"node_modules/@tonaljs/voicing-dictionary": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@tonaljs/voicing-dictionary/-/voicing-dictionary-5.1.3.tgz",
"integrity": "sha512-kRM6IOHq+ULgZtJdbGNz5OmfUo+WyA3EeqKVrlA9Uj7QqrrfOkzeQdE3jXEBnjO1oGP5ql6NS5QUF3rST+SqUQ==",
"license": "MIT",
"dependencies": {
"@tonaljs/chord": "6.1.2",
"@tonaljs/note": "4.12.1",
"@tonaljs/voice-leading": "5.1.2"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -13720,6 +14010,37 @@
"node": ">=8.0"
}
},
"node_modules/tonal": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/tonal/-/tonal-6.4.3.tgz",
"integrity": "sha512-p+0kwBBma3Ko2xtHVq8oYa+IAGRozkAkBk03swRvcEfxYa8Ktr0yrBYW1fsZlywaerEu346oebghrS8jDQmBsA==",
"license": "MIT",
"dependencies": {
"@tonaljs/abc-notation": "4.9.1",
"@tonaljs/array": "4.8.4",
"@tonaljs/chord": "6.1.2",
"@tonaljs/chord-type": "5.1.1",
"@tonaljs/collection": "4.9.0",
"@tonaljs/core": "5.0.2",
"@tonaljs/duration-value": "4.9.0",
"@tonaljs/interval": "5.1.0",
"@tonaljs/key": "4.11.2",
"@tonaljs/midi": "4.10.2",
"@tonaljs/mode": "4.9.2",
"@tonaljs/note": "4.12.1",
"@tonaljs/pcset": "4.10.1",
"@tonaljs/progression": "4.9.2",
"@tonaljs/range": "4.9.2",
"@tonaljs/rhythm-pattern": "1.0.0",
"@tonaljs/roman-numeral": "4.9.1",
"@tonaljs/scale": "4.13.4",
"@tonaljs/scale-type": "4.9.2",
"@tonaljs/time-signature": "4.9.0",
"@tonaljs/voice-leading": "5.1.2",
"@tonaljs/voicing": "5.1.3",
"@tonaljs/voicing-dictionary": "5.1.3"
}
},
"node_modules/tone": {
"version": "15.1.22",
"resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz",
+1
View File
@@ -31,6 +31,7 @@
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tonal": "^6.4.3",
"tone": "^15.1.22",
"vexflow": "^5.0.0",
"zustand": "^5.0.6"
+168
View File
@@ -0,0 +1,168 @@
.chord-picker-popup {
min-width: min(92vw, 270px);
padding: 16px 16px 14px;
color: #e0e0e0;
font-family: inherit;
border-radius: 24px;
}
.chord-picker-header,
.chord-picker-section,
.chord-picker-footer {
position: relative;
}
.chord-picker-section + .chord-picker-section {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(68, 79, 94, 0.16);
}
.chord-picker-header {
margin-bottom: 14px;
}
.chord-picker-input-shell {
display: grid;
gap: 8px;
}
.chord-picker-input-label,
.chord-picker-section-header span {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.01em;
}
.chord-picker-input {
width: 100%;
padding: 8px 12px;
background-color: #4a4a4a;
border: 1px solid #5a5a5a;
border-radius: 6px;
color: #e0e0e0;
font-size: 12px;
outline: none;
min-height: 36px;
line-height: 1.4;
}
.chord-picker-input.invalid {
border-color: #d45a5a;
}
.chord-picker-input:focus {
border-color: #5a9fd4;
background-color: #4a4a4a;
}
.chord-picker-input::placeholder {
color: #999;
}
.chord-picker-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.chord-picker-root-dropdown {
z-index: 1;
}
.chord-picker-root-button.quant-button {
margin-left: 0;
min-width: 72px;
height: 24px;
justify-content: space-between;
padding: 0 8px;
}
.chord-picker-grid {
display: grid;
gap: 10px;
}
.chord-picker-button-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 52px));
justify-content: space-between;
gap: 6px;
}
.chord-picker-button {
--toolbar-button-bg: transparent;
--toolbar-button-fg: #e0e0e0;
border: none;
border-radius: 3px;
background: transparent;
background-color: var(--toolbar-button-bg);
color: var(--toolbar-button-fg);
min-height: 24px;
height: 24px;
padding: 0 6px;
font-size: 12px;
font-weight: 500;
transition:
background-color 0.18s ease,
color 0.18s ease,
transform 0.12s ease,
box-shadow 0.18s ease;
}
.chord-picker-button:hover {
--toolbar-button-bg: #3a3a3a;
}
.chord-picker-button.selected {
--toolbar-button-bg: #e0e0e0;
--toolbar-button-fg: #2d2d2d;
}
.chord-picker-button.selected:hover {
--toolbar-button-bg: #f0f0f0;
}
.chord-picker-button:active {
--toolbar-button-bg: #5a5a5a;
transform: scale(0.95);
}
.chord-picker-button.selected:active {
--toolbar-button-bg: #d6d6d6;
}
.chord-picker-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
min-height: 24px;
}
.chord-picker-preview {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: #b8b8b8;
}
.chord-picker-error {
font-size: 12px;
font-weight: 700;
color: #d45a5a;
}
@media (max-width: 720px) {
.chord-picker-popup {
min-width: min(94vw, 270px);
padding: 14px 12px 12px;
}
.chord-picker-button-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+75
View File
@@ -0,0 +1,75 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import ChordPickerPopup from './ChordPickerPopup';
describe('ChordPickerPopup', () => {
it('parses text input on enter and syncs the related buttons', () => {
const onChange = vi.fn();
render(<ChordPickerPopup value="C" onChange={onChange} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'Bm7b5' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('Bm7b5');
expect(screen.getByRole('button', { name: 'Dim' }).className).toContain('selected');
expect(screen.getByRole('button', { name: 'b5' }).className).toContain('selected');
expect(screen.getByRole('button', { name: '7' }).className).toContain('selected');
});
it('shows an inline error for invalid text without mutating the region', () => {
const onChange = vi.fn();
render(<ChordPickerPopup value="C" onChange={onChange} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'invalid' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByText('Unable to parse chord')).toBeInTheDocument();
});
it('intercepts tab and delegates popup bar navigation', () => {
const onTabNavigate = vi.fn();
render(<ChordPickerPopup value="C" onChange={vi.fn()} onTabNavigate={onTabNavigate} />);
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
expect(onTabNavigate).toHaveBeenCalledWith('forward');
});
it('intercepts shift-tab and delegates backward popup bar navigation', () => {
const onTabNavigate = vi.fn();
render(<ChordPickerPopup value="C" onChange={vi.fn()} onTabNavigate={onTabNavigate} />);
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab', shiftKey: true });
expect(onTabNavigate).toHaveBeenCalledWith('backward');
});
it('focuses the chord input when the popup opens', () => {
render(<ChordPickerPopup value="C" onChange={vi.fn()} />);
expect(screen.getByRole('textbox')).toHaveFocus();
});
it('blocks tab navigation when the current input is invalid', () => {
const onChange = vi.fn();
const onTabNavigate = vi.fn();
render(<ChordPickerPopup value="C" onChange={onChange} onTabNavigate={onTabNavigate} />);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'invalid' } });
fireEvent.keyDown(input, { key: 'Tab' });
expect(onChange).not.toHaveBeenCalled();
expect(onTabNavigate).not.toHaveBeenCalled();
expect(screen.getByText('Unable to parse chord')).toBeInTheDocument();
});
});
+317
View File
@@ -0,0 +1,317 @@
import React from 'react';
import './ChordPickerPopup.css';
import KGDropdown from './common/KGDropdown';
import {
buildChordSymbol,
CHORD_EXTENSION_OPTIONS,
formatChordSymbolForDisplay,
type ChordDescriptor,
type ChordExtension,
type ChordQuality,
parseChordSymbol,
} from '../util/chordUtil';
interface ChordPickerPopupProps {
value: string;
onChange: (symbol: string) => void;
onTabNavigate?: (direction: 'forward' | 'backward') => void;
}
const ROOT_OPTIONS = ['C', 'C#', 'Db', 'D', 'D#', 'Eb', 'E', 'F', 'F#', 'Gb', 'G', 'G#', 'Ab', 'A', 'A#', 'Bb', 'B'] as const;
const QUALITY_LABELS: Record<ChordQuality, string> = {
maj: 'Maj',
min: 'Min',
sus2: 'Sus2',
sus4: 'Sus4',
power: '5',
aug: 'Aug',
dim: 'Dim',
};
const EXTENSION_LABELS: Record<ChordExtension, string> = {
b5: 'b5',
'#5': '#5',
'6': '6',
'7': '7',
maj7: 'maj7',
b9: 'b9',
'9': '9',
'#9': '#9',
'11': '11',
'#11': '#11',
b13: 'b13',
'13': '13',
};
const QUALITY_ROWS: ChordQuality[][] = [
['maj', 'min', 'sus2', 'sus4'],
['power', 'aug', 'dim'],
];
const EXTENSION_ROWS: ChordExtension[][] = [
['b5', '#5', '6', '7'],
['maj7', 'b9', '9', '#9'],
['11', '#11', 'b13', '13'],
];
function createFallbackDescriptor(value: string): ChordDescriptor {
return {
root: 'C',
quality: 'maj',
extensions: [],
symbol: value || 'C',
};
}
function normalizeDescriptor(descriptor: ChordDescriptor): ChordDescriptor {
let quality = descriptor.quality;
let extensions = [...descriptor.extensions];
const dedupe = (nextExtensions: ChordExtension[]) => Array.from(new Set(nextExtensions));
const removeExtensions = (targets: ChordExtension[]) => {
extensions = extensions.filter(extension => !targets.includes(extension));
};
if (quality === 'aug' && !extensions.includes('#5')) {
extensions.push('#5');
}
if (quality === 'dim' && !extensions.includes('b5')) {
extensions.push('b5');
}
if (quality === 'aug') {
removeExtensions(['b5']);
}
if (quality === 'dim') {
removeExtensions(['#5']);
}
if (extensions.includes('7')) {
removeExtensions(['maj7', '6']);
}
if (extensions.includes('maj7')) {
removeExtensions(['7', '6']);
}
if (extensions.includes('6')) {
removeExtensions(['7', 'maj7', '13', 'b13']);
}
if (extensions.includes('13') || extensions.includes('b13')) {
removeExtensions(['6']);
}
if (extensions.includes('b9')) {
removeExtensions(['9', '#9']);
} else if (extensions.includes('9')) {
removeExtensions(['b9', '#9']);
} else if (extensions.includes('#9')) {
removeExtensions(['b9', '9']);
}
if (extensions.includes('11')) {
removeExtensions(['#11']);
} else if (extensions.includes('#11')) {
removeExtensions(['11']);
}
if (extensions.includes('b13')) {
removeExtensions(['13']);
} else if (extensions.includes('13')) {
removeExtensions(['b13']);
}
return {
...descriptor,
quality,
extensions: CHORD_EXTENSION_OPTIONS.filter(extension => dedupe(extensions).includes(extension)),
};
}
const ChordPickerPopup: React.FC<ChordPickerPopupProps> = ({ value, onChange, onTabNavigate }) => {
const [descriptor, setDescriptor] = React.useState<ChordDescriptor>(() => parseChordSymbol(value) ?? createFallbackDescriptor(value));
const [inputText, setInputText] = React.useState(value);
const [error, setError] = React.useState<string | null>(null);
const [showRootDropdown, setShowRootDropdown] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
const parsed = parseChordSymbol(value);
setDescriptor(parsed ?? createFallbackDescriptor(value));
setInputText(value);
setError(null);
setShowRootDropdown(false);
}, [value]);
React.useEffect(() => {
if (!inputRef.current) {
return;
}
inputRef.current.focus();
inputRef.current.select();
}, [value]);
const applyDescriptor = React.useCallback((nextDescriptor: ChordDescriptor) => {
const normalized = normalizeDescriptor(nextDescriptor);
const nextSymbol = buildChordSymbol(normalized);
if (!nextSymbol) {
setError('Unsupported chord combination');
return;
}
const parsed = parseChordSymbol(nextSymbol);
if (!parsed) {
setError('Unsupported chord combination');
return;
}
setDescriptor(parsed);
setInputText(parsed.symbol);
setError(null);
onChange(parsed.symbol);
}, [onChange]);
const handleQualityChange = React.useCallback((quality: ChordQuality) => {
applyDescriptor({
...descriptor,
quality,
});
}, [applyDescriptor, descriptor]);
const handleExtensionToggle = React.useCallback((extension: ChordExtension) => {
const isSelected = descriptor.extensions.includes(extension);
const nextExtensions = isSelected
? descriptor.extensions.filter(candidate => candidate !== extension)
: [...descriptor.extensions, extension];
let nextQuality = descriptor.quality;
if (!isSelected && extension === 'b5' && nextQuality === 'aug') {
nextQuality = 'maj';
}
if (!isSelected && extension === '#5' && nextQuality === 'dim') {
nextQuality = 'maj';
}
applyDescriptor({
...descriptor,
quality: nextQuality,
extensions: nextExtensions,
});
}, [applyDescriptor, descriptor]);
const handleSubmit = React.useCallback(() => {
const parsed = parseChordSymbol(inputText);
if (!parsed) {
setError('Unable to parse chord');
return false;
}
setDescriptor(parsed);
setInputText(parsed.symbol);
setError(null);
onChange(parsed.symbol);
return true;
}, [inputText, onChange]);
return (
<div
className="chord-picker-popup"
onKeyDownCapture={(event) => {
if (event.key === 'Tab' && onTabNavigate) {
event.preventDefault();
event.stopPropagation();
const isValid = handleSubmit();
if (!isValid) {
return;
}
onTabNavigate(event.shiftKey ? 'backward' : 'forward');
}
}}
>
<div className="chord-picker-header">
<label className="chord-picker-input-shell">
<span className="chord-picker-input-label">Chord</span>
<input
ref={inputRef}
className={`chord-picker-input${error ? ' invalid' : ''}`}
value={inputText}
onChange={(event) => {
setInputText(event.target.value);
if (error) {
setError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSubmit();
}
}}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
placeholder="Type a chord"
/>
</label>
</div>
<div className="chord-picker-section">
<div className="chord-picker-section-header">
<span>Root Note</span>
<KGDropdown
options={[...ROOT_OPTIONS]}
value={descriptor.root}
onChange={(nextRoot) => applyDescriptor({ ...descriptor, root: nextRoot })}
label="Root Note"
showValueAsLabel={true}
isOpen={showRootDropdown}
onToggle={setShowRootDropdown}
buttonClassName="chord-picker-root-button"
className="chord-picker-root-dropdown"
/>
</div>
</div>
<div className="chord-picker-section chord-picker-grid">
{QUALITY_ROWS.map((row, rowIndex) => (
<div className="chord-picker-button-row" key={`quality-row-${rowIndex}`}>
{row.map((quality) => (
<button
key={quality}
type="button"
aria-label={QUALITY_LABELS[quality]}
className={`chord-picker-button quality${descriptor.quality === quality ? ' selected' : ''}`}
onClick={() => handleQualityChange(quality)}
>
{QUALITY_LABELS[quality]}
</button>
))}
</div>
))}
</div>
<div className="chord-picker-section chord-picker-grid">
{EXTENSION_ROWS.map((row, rowIndex) => (
<div className="chord-picker-button-row" key={`extension-row-${rowIndex}`}>
{row.map((extension) => (
<button
key={extension}
type="button"
aria-label={EXTENSION_LABELS[extension]}
className={`chord-picker-button extension${descriptor.extensions.includes(extension) ? ' selected' : ''}`}
onClick={() => handleExtensionToggle(extension)}
>
{EXTENSION_LABELS[extension]}
</button>
))}
</div>
))}
</div>
<div className="chord-picker-footer">
<span className="chord-picker-preview">{formatChordSymbolForDisplay(descriptor.symbol || value)}</span>
{error && <span className="chord-picker-error">{error}</span>}
</div>
</div>
);
};
export default ChordPickerPopup;
+42
View File
@@ -311,6 +311,10 @@
z-index: 1004;
}
.global-chord-lane.popup-open {
z-index: 1004;
}
.global-marker-lane.pencil-cursor {
cursor: crosshair;
}
@@ -355,6 +359,12 @@
overflow: visible;
}
.global-chord-region {
background: #dc8b3f;
color: #2f2215;
overflow: visible;
}
.global-key-signature-region .floating-popup {
min-width: 0;
width: var(--track-grid-bar-width);
@@ -385,6 +395,38 @@
z-index: 1009;
}
.global-chord-region .floating-popup {
min-width: 0;
width: var(--track-grid-bar-width);
}
.global-chord-popup-anchor {
min-width: 0;
width: var(--track-grid-bar-width);
position: absolute;
left: calc((var(--track-grid-bar-width) - var(--track-grid-bar-width)) / 2);
top: 50%;
transform: translateY(-50%);
z-index: 1008;
}
.global-chord-trigger {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.global-chord-popup-surface {
--floating-popup-bg: #2d2d2d;
--floating-popup-border: #555;
z-index: 10007;
}
.global-chord-popup-anchor .floating-popup-surface {
z-index: 10009;
}
.global-marker-label {
overflow: hidden;
text-overflow: ellipsis;
+86 -2
View File
@@ -4,8 +4,9 @@ import { fireEvent, render, screen } from '@testing-library/react';
import MainContent from './MainContent';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import { KGChordRegion } from '../core/region/KGChordRegion';
import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { createDefaultGlobalTracks } from '../core/global-track';
import { createDefaultGlobalTracks, GlobalTrackType } from '../core/global-track';
import { createMockMidiTrack } from '../test/utils/mock-data';
const executeCommandMock = vi.fn();
@@ -138,9 +139,11 @@ vi.mock('./piano-roll/PianoRoll', () => ({
describe('MainContent', () => {
beforeEach(() => {
storeState.globalTracks = createDefaultGlobalTracks();
storeState.selectedRegionIds = [];
storeState.activeRegionId = null;
storeState.showPianoRoll = false;
storeState.playheadPosition = 0;
storeState.timeSignature = { numerator: 4, denominator: 4 };
storeState.clearAllSelections.mockClear();
storeState.setSelectedTrack.mockClear();
@@ -332,7 +335,8 @@ describe('MainContent', () => {
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
});
it('routes the tempo global track add button through a command and keeps chord visual-only', () => {
it('routes the tempo and chord global track add buttons through commands', () => {
storeState.playheadPosition = 5;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
@@ -344,7 +348,87 @@ describe('MainContent', () => {
expect(storeState.addTrack).not.toHaveBeenCalled();
expect(storeState.addAudioTrack).not.toHaveBeenCalled();
expect(executeCommandMock).toHaveBeenCalledTimes(2);
expect((executeCommandMock.mock.calls[1][0] as { startBeat?: number }).startBeat).toBe(5);
});
it('uses split-insert chord command when the playhead is inside an existing chord region', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
chordTrack?.addRegion(new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8));
storeState.globalTracks = globalTracks;
storeState.playheadPosition = 3;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.click(screen.getByRole('button', { name: 'Add Chord global track item' }));
expect(executeCommandMock).toHaveBeenCalledTimes(1);
expect((executeCommandMock.mock.calls[0][0] as { insertBeat?: number }).insertBeat).toBe(3);
storeState.globalTracks = createDefaultGlobalTracks();
});
it('tabs the open chord popup to the next chord region before the next bar', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
chordTrack?.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 3),
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 2, 2),
]);
storeState.globalTracks = globalTracks;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.doubleClick(screen.getByText('Am'));
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
expect(storeState.selectedRegionIds).toEqual(['chord-2']);
storeState.globalTracks = createDefaultGlobalTracks();
});
it('tabs the open chord popup to insert at the next exact bar when no chord starts before it', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
chordTrack?.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
]);
storeState.globalTracks = globalTracks;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.doubleClick(screen.getByText('Am'));
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
expect(executeCommandMock).toHaveBeenCalledTimes(1);
expect((executeCommandMock.mock.calls[0][0] as { insertBeat?: number }).insertBeat).toBe(4);
storeState.globalTracks = createDefaultGlobalTracks();
});
it('shift-tabs the open chord popup to the previous existing chord without creating a new one', () => {
const globalTracks = createDefaultGlobalTracks();
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord);
chordTrack?.setRegions([
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 4),
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 8, 4),
]);
storeState.globalTracks = globalTracks;
render(<MainContent />);
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
fireEvent.doubleClick(screen.getByText('G'));
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab', shiftKey: true });
expect(storeState.selectedRegionIds).toEqual(['chord-1']);
expect(executeCommandMock).toHaveBeenCalledTimes(0);
storeState.globalTracks = createDefaultGlobalTracks();
});
it('routes the signature global track add button through a command', () => {
+184 -1
View File
@@ -8,6 +8,7 @@ import { GlobalTrackType } from '../core/global-track';
import { KGTrack } from '../core/track/KGTrack';
import { KGRegion } from '../core/region/KGRegion';
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
import { KGChordRegion } from '../core/region/KGChordRegion';
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
import { KGTempoRegion } from '../core/region/KGTempoRegion';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
@@ -18,6 +19,7 @@ import TrackGridPanel from './track/TrackGridPanel';
import GlobalKeySignatureLane from './global-track/GlobalKeySignatureLane';
import GlobalTempoLane from './global-track/GlobalTempoLane';
import GlobalMarkerLane from './global-track/GlobalMarkerLane';
import GlobalChordLane from './global-track/GlobalChordLane';
import PianoRoll from './piano-roll/PianoRoll';
import { TrackCreateDialog } from './common';
import type { RegionClickOptions, RegionUI } from './interfaces';
@@ -27,6 +29,7 @@ import { regionDeleteManager } from '../util/regionDeleteUtil';
import { KGMainContentState } from '../core/state/KGMainContentState';
import {
ChangeLoopSettingsCommand,
CreateChordRegionCommand,
CreateGlobalMarkerRegionCommand,
CreateKeySignatureRegionCommand,
CreateTempoRegionCommand,
@@ -36,10 +39,12 @@ import {
DeleteTempoRegionCommand,
DeleteMultipleGlobalRegionsCommand,
DeleteTrackAutomationPointsCommand,
InsertChordRegionAtBeatCommand,
MoveGlobalRegionCommand,
ResizeKeySignatureRegionCommand,
ResizeTempoRegionCommand,
ResizeGlobalRegionCommand,
UpdateChordRegionCommand,
UpdateKeySignatureRegionCommand,
UpdateGlobalRegionTextCommand,
UpdateTempoRegionCommand,
@@ -130,6 +135,7 @@ const MainContent: React.FC<MainContentProps> = ({
const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState<string | null>(null);
const [editingTempoRegionId, setEditingTempoRegionId] = useState<string | null>(null);
const [editingTempoText, setEditingTempoText] = useState('');
const [editingChordRegionId, setEditingChordRegionId] = useState<string | null>(null);
// Use the region operations hook
const { deleteSelectedRegions } = useRegionOperations({
@@ -186,6 +192,10 @@ const MainContent: React.FC<MainContentProps> = ({
const signatureRegions = signatureTrack ? getSortedKeySignatureRegions(signatureTrack, timeSignature.numerator) : [];
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null;
const tempoRegions = tempoTrack ? getSortedTempoRegions(tempoTrack, timeSignature.numerator) : [];
const chordTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Chord) ?? null;
const chordRegions = (chordTrack?.getRegions() ?? []).filter(
(region): region is KGChordRegion => region instanceof KGChordRegion
);
const findProjectRegionById = useCallback((regionId: string): KGRegion | null => {
for (const track of tracks) {
@@ -251,13 +261,16 @@ const MainContent: React.FC<MainContentProps> = ({
setEditingTempoRegionId(null);
setEditingTempoText('');
}
if (editingChordRegionId && selectedGlobalRegionIds.includes(editingChordRegionId)) {
setEditingChordRegionId(null);
}
refreshProjectState();
return true;
} catch (error) {
console.error('Error deleting global marker regions:', error);
return false;
}
}, [bumpAudioWaveformRedrawVersion, editingGlobalRegionId, editingKeySignatureRegionId, editingTempoRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
}, [bumpAudioWaveformRedrawVersion, editingChordRegionId, editingGlobalRegionId, editingKeySignatureRegionId, editingTempoRegionId, findProjectRegionById, isGlobalRegionId, refreshProjectState, selectedRegionIds]);
// Register the delete function with the global manager
useEffect(() => {
@@ -952,6 +965,15 @@ const MainContent: React.FC<MainContentProps> = ({
setEditingTempoText(region.getBpm().toString());
}, [findProjectRegionById]);
const beginEditingChordRegion = useCallback((regionId: string) => {
const region = findProjectRegionById(regionId);
if (!(region instanceof KGChordRegion)) {
return;
}
setEditingChordRegionId(regionId);
}, [findProjectRegionById]);
const commitGlobalRegionEdit = useCallback((regionId: string) => {
const region = findProjectRegionById(regionId);
if (!(region instanceof KGMarkerRegion)) {
@@ -1160,6 +1182,144 @@ const MainContent: React.FC<MainContentProps> = ({
}
}, [bumpAudioWaveformRedrawVersion, refreshProjectState]);
const createChordAtBeat = useCallback((requestedStartBeat: number) => {
const normalizedStartBeat = Math.max(0, Math.round(requestedStartBeat));
const occupiedRegion = chordRegions.find(region => (
normalizedStartBeat >= region.getStartFromBeat()
&& normalizedStartBeat < region.getStartFromBeat() + region.getLength()
));
if (occupiedRegion) {
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
beginEditingChordRegion(occupiedRegion.getId());
return;
}
try {
const command = new CreateChordRegionCommand(normalizedStartBeat, timeSignature.numerator, 'C');
KGCore.instance().executeCommand(command);
refreshProjectState();
const createdRegion = command.getCreatedRegion();
if (!createdRegion) {
return;
}
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
setEditingChordRegionId(createdRegion.getId());
} catch (error) {
console.error('Error creating chord region:', error);
}
}, [beginEditingChordRegion, chordRegions, refreshProjectState, selectGlobalRegion, timeSignature.numerator]);
const createChordAtExactBeat = useCallback((requestedStartBeat: number) => {
const normalizedStartBeat = Math.max(0, Math.round(requestedStartBeat));
const occupiedRegion = chordRegions.find(region => (
normalizedStartBeat > region.getStartFromBeat()
&& normalizedStartBeat < region.getStartFromBeat() + region.getLength()
));
try {
const command = occupiedRegion
? new InsertChordRegionAtBeatCommand(normalizedStartBeat, 'C')
: new CreateChordRegionCommand(normalizedStartBeat, timeSignature.numerator, 'C');
KGCore.instance().executeCommand(command);
refreshProjectState();
const createdRegion = command.getCreatedRegion();
if (!createdRegion) {
if (occupiedRegion) {
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
beginEditingChordRegion(occupiedRegion.getId());
}
return null;
}
selectGlobalRegion(createdRegion.getId(), { shiftKey: false });
setEditingChordRegionId(createdRegion.getId());
return createdRegion;
} catch (error) {
console.error('Error creating chord region at exact beat:', error);
if (occupiedRegion) {
selectGlobalRegion(occupiedRegion.getId(), { shiftKey: false });
beginEditingChordRegion(occupiedRegion.getId());
}
return null;
}
}, [beginEditingChordRegion, chordRegions, refreshProjectState, selectGlobalRegion, timeSignature.numerator]);
const createChordAtPlayheadBeat = useCallback(() => {
createChordAtExactBeat(playheadPosition);
}, [createChordAtExactBeat, playheadPosition]);
const navigateChordPopupByBar = useCallback((currentRegionId: string, direction: 'forward' | 'backward') => {
const currentRegion = chordRegions.find(region => region.getId() === currentRegionId);
if (!currentRegion) {
return;
}
const beatsPerBar = timeSignature.numerator;
const currentStartBeat = currentRegion.getStartFromBeat();
const currentBarBeat = Math.floor(currentStartBeat / beatsPerBar) * beatsPerBar;
const targetBarBeat = direction === 'forward'
? currentBarBeat + beatsPerBar
: currentBarBeat - beatsPerBar;
const songEndBeat = maxBars * beatsPerBar;
if (targetBarBeat < 0 || targetBarBeat >= songEndBeat) {
return;
}
const sortedRegions = [...chordRegions]
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
const targetRegion = direction === 'forward'
? sortedRegions.find(region => region.getStartFromBeat() > currentStartBeat && region.getStartFromBeat() <= targetBarBeat)
: [...sortedRegions]
.reverse()
.find(region => region.getStartFromBeat() < currentStartBeat);
if (targetRegion) {
selectGlobalRegion(targetRegion.getId(), { shiftKey: false });
setEditingChordRegionId(targetRegion.getId());
return;
}
if (direction === 'forward') {
createChordAtExactBeat(targetBarBeat);
}
}, [chordRegions, createChordAtExactBeat, maxBars, selectGlobalRegion, timeSignature.numerator]);
const moveGlobalChordRegion = useCallback((regionId: string, startBeat: number) => {
try {
KGCore.instance().executeCommand(new MoveGlobalRegionCommand(regionId, Math.round(startBeat)));
refreshProjectState();
} catch (error) {
console.error('Error moving chord region:', error);
}
}, [refreshProjectState]);
const resizeGlobalChordRegion = useCallback((regionId: string, edge: 'start' | 'end', beat: number) => {
try {
KGCore.instance().executeCommand(new ResizeGlobalRegionCommand(regionId, edge, Math.round(beat)));
refreshProjectState();
} catch (error) {
console.error('Error resizing chord region:', error);
}
}, [refreshProjectState]);
const updateChordRegion = useCallback((regionId: string, symbol: string) => {
const region = findProjectRegionById(regionId);
if (!(region instanceof KGChordRegion) || region.getSymbol() === symbol) {
return;
}
try {
KGCore.instance().executeCommand(new UpdateChordRegionCommand(regionId, symbol));
refreshProjectState();
} catch (error) {
console.error('Error updating chord region:', error);
}
}, [findProjectRegionById, refreshProjectState]);
/**
* Add keyboard event listener for region deletion
* Handles Backspace (Windows) and Delete (Mac) keys to delete selected regions
@@ -1520,6 +1680,11 @@ const MainContent: React.FC<MainContentProps> = ({
if (track.id === 'signature') {
createKeySignatureAtPlayheadBar();
return;
}
if (track.id === 'chord') {
createChordAtPlayheadBeat();
}
}}
>
@@ -1590,6 +1755,24 @@ const MainContent: React.FC<MainContentProps> = ({
onChangeKeySignature={updateKeySignatureRegion}
onOpenPicker={beginEditingKeySignatureRegion}
/>
) : track.id === 'chord' ? (
<GlobalChordLane
key={track.id}
chordRegions={chordRegions}
maxBars={maxBars}
barWidthMultiplier={barWidthMultiplier}
timeSignature={timeSignature}
selectedRegionIds={selectedRegionIds}
popupRegionId={editingChordRegionId}
onClosePopup={() => setEditingChordRegionId(null)}
onSelectRegion={selectGlobalRegion}
onCreateAtBeat={createChordAtBeat}
onMoveRegion={moveGlobalChordRegion}
onResizeRegion={resizeGlobalChordRegion}
onChangeChord={updateChordRegion}
onOpenPopup={beginEditingChordRegion}
onTabNavigate={navigateChordPopupByBar}
/>
) : (
<div
key={track.id}
@@ -26,4 +26,27 @@ describe('FloatingPopup', () => {
expect(onClose).toHaveBeenCalledTimes(2);
});
it('stops escape from bubbling to outer window listeners', () => {
const onClose = vi.fn();
const outerEscapeHandler = vi.fn();
window.addEventListener('keydown', outerEscapeHandler);
render(
<FloatingPopup
isOpen={true}
onClose={onClose}
trigger={<button type="button">Toggle</button>}
>
<div>Popup body</div>
</FloatingPopup>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
expect(outerEscapeHandler).not.toHaveBeenCalled();
window.removeEventListener('keydown', outerEscapeHandler);
});
});
+15 -3
View File
@@ -33,6 +33,7 @@ const FloatingPopup: React.FC<FloatingPopupProps> = ({
}) => {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLDivElement | null>(null);
const surfaceRef = React.useRef<HTMLDivElement | null>(null);
const [portalStyle, setPortalStyle] = React.useState<React.CSSProperties | undefined>(undefined);
React.useLayoutEffect(() => {
@@ -93,33 +94,44 @@ const FloatingPopup: React.FC<FloatingPopupProps> = ({
}
const handleMouseDown = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
const targetNode = event.target as Node;
const isInsideRoot = rootRef.current?.contains(targetNode) ?? false;
const isInsideSurface = surfaceRef.current?.contains(targetNode) ?? false;
if (!isInsideRoot && !isInsideSurface) {
onClose();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
onClose();
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [isOpen, onClose]);
const popupSurface = isOpen ? (
<div
ref={surfaceRef}
className={`floating-popup-surface ${contentClassName}`.trim()}
data-placement={placement}
role="dialog"
aria-modal="false"
style={renderInPortal ? portalStyle : undefined}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
>
<div
className={`floating-popup-arrow ${arrowClassName}`.trim()}
@@ -0,0 +1,120 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import GlobalChordLane from './GlobalChordLane';
import { KGChordRegion } from '../../core/region/KGChordRegion';
describe('GlobalChordLane', () => {
const baseRegion = new KGChordRegion('chord-1', 'global-chord', 3, 'Cmaj7', 0, 4);
beforeEach(() => {
document.documentElement.style.setProperty('--track-grid-bar-width', '40');
});
it('opens the chord popup for an existing region', () => {
const onOpenPopup = vi.fn();
render(
<GlobalChordLane
chordRegions={[baseRegion]}
maxBars={8}
barWidthMultiplier={1}
timeSignature={{ numerator: 4, denominator: 4 }}
selectedRegionIds={[]}
popupRegionId={null}
onClosePopup={vi.fn()}
onSelectRegion={vi.fn()}
onCreateAtBeat={vi.fn()}
onMoveRegion={vi.fn()}
onResizeRegion={vi.fn()}
onChangeChord={vi.fn()}
onOpenPopup={onOpenPopup}
/>
);
fireEvent.doubleClick(screen.getByText('Cmaj7'));
expect(onOpenPopup).toHaveBeenCalledWith('chord-1');
});
it('creates a new region at a bar-aligned beat on empty-lane double click and modifier click', () => {
const onCreateAtBeat = vi.fn();
const { container } = render(
<GlobalChordLane
chordRegions={[baseRegion]}
maxBars={8}
barWidthMultiplier={1}
timeSignature={{ numerator: 4, denominator: 4 }}
selectedRegionIds={[]}
popupRegionId={null}
onClosePopup={vi.fn()}
onSelectRegion={vi.fn()}
onCreateAtBeat={onCreateAtBeat}
onMoveRegion={vi.fn()}
onResizeRegion={vi.fn()}
onChangeChord={vi.fn()}
onOpenPopup={vi.fn()}
/>
);
const lane = container.querySelector('.global-chord-lane') as HTMLDivElement;
vi.spyOn(lane, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 320,
bottom: 24,
width: 320,
height: 24,
toJSON: () => ({}),
});
fireEvent.doubleClick(lane, { clientX: 159, clientY: 10 });
fireEvent.mouseDown(lane, { clientX: 81, clientY: 10, ctrlKey: true, button: 0 });
expect(onCreateAtBeat).toHaveBeenNthCalledWith(1, 16);
expect(onCreateAtBeat).toHaveBeenNthCalledWith(2, 8);
});
it('snaps drag moves to whole beats', async () => {
const onMoveRegion = vi.fn();
render(
<GlobalChordLane
chordRegions={[baseRegion]}
maxBars={8}
barWidthMultiplier={1}
timeSignature={{ numerator: 4, denominator: 4 }}
selectedRegionIds={[]}
popupRegionId={null}
onClosePopup={vi.fn()}
onSelectRegion={vi.fn()}
onCreateAtBeat={vi.fn()}
onMoveRegion={onMoveRegion}
onResizeRegion={vi.fn()}
onChangeChord={vi.fn()}
onOpenPopup={vi.fn()}
/>
);
const region = screen.getByText('Cmaj7').closest('.global-chord-region') as HTMLDivElement;
vi.spyOn(region, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 30,
bottom: 24,
width: 30,
height: 24,
toJSON: () => ({}),
});
fireEvent.mouseDown(region, { clientX: 15, clientY: 10, button: 0 });
fireEvent.mouseMove(window, { clientX: 32, clientY: 10 });
await waitFor(() => expect(region.style.left).toBe('20px'));
fireEvent.mouseUp(window, { clientX: 32, clientY: 10 });
expect(onMoveRegion).toHaveBeenCalledWith('chord-1', 2);
});
});
@@ -0,0 +1,365 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { KGChordRegion } from '../../core/region/KGChordRegion';
import type { RegionClickOptions } from '../interfaces';
import { isModifierKeyPressed } from '../../util/osUtil';
import { TOOLBAR_CONSTANTS } from '../../constants';
import FloatingPopup from '../common/FloatingPopup';
import ChordPickerPopup from '../ChordPickerPopup';
interface GlobalChordLaneProps {
chordRegions: KGChordRegion[];
maxBars: number;
barWidthMultiplier: number;
timeSignature: { numerator: number; denominator: number };
selectedRegionIds: string[];
popupRegionId: string | null;
onClosePopup: () => void;
onSelectRegion: (regionId: string, options?: RegionClickOptions) => void;
onCreateAtBeat: (startBeat: number) => void;
onMoveRegion: (regionId: string, startBeat: number) => void;
onResizeRegion: (regionId: string, edge: 'start' | 'end', beat: number) => void;
onChangeChord: (regionId: string, symbol: string) => void;
onOpenPopup: (regionId: string) => void;
onTabNavigate: (regionId: string, direction: 'forward' | 'backward') => void;
}
type ResizeEdge = 'start' | 'end' | null;
const REGION_EDGE_HITBOX_PX = 8;
const DRAG_THRESHOLD_PX = 4;
const GlobalChordLane: React.FC<GlobalChordLaneProps> = ({
chordRegions,
maxBars,
barWidthMultiplier,
timeSignature,
selectedRegionIds,
popupRegionId,
onClosePopup,
onSelectRegion,
onCreateAtBeat,
onMoveRegion,
onResizeRegion,
onChangeChord,
onOpenPopup,
onTabNavigate,
}) => {
const laneRef = useRef<HTMLDivElement | null>(null);
const [previewBeats, setPreviewBeats] = useState<Record<string, { startBeat: number; length: number }>>({});
const [hoverEdges, setHoverEdges] = useState<Record<string, ResizeEdge>>({});
const [isModifierPressed, setIsModifierPressed] = useState(false);
const interactionRef = useRef<{
mode: 'drag' | 'resize' | null;
regionId: string;
initialMouseX: number;
initialStartBeat: number;
initialLength: number;
resizeEdge: ResizeEdge;
moved: boolean;
} | null>(null);
const totalBeats = maxBars * timeSignature.numerator;
const beatWidth = useMemo(() => {
const barWidth = TOOLBAR_CONSTANTS.BASE_BAR_WIDTH * barWidthMultiplier;
return barWidth / timeSignature.numerator;
}, [barWidthMultiplier, timeSignature.numerator]);
const clampStartBeat = (value: number) => Math.max(0, Math.min(totalBeats - 1, value));
const clampEndBeat = (value: number) => Math.max(1, Math.min(totalBeats, value));
const beatsPerBar = timeSignature.numerator;
const getBeatFromClientX = (clientX: number, mode: 'start' | 'end' = 'start') => {
if (!laneRef.current) {
return 0;
}
const rect = laneRef.current.getBoundingClientRect();
const relativeX = clientX - rect.left;
const rawBeat = relativeX / beatWidth;
return mode === 'end'
? clampEndBeat(Math.round(rawBeat))
: clampStartBeat(Math.round(rawBeat));
};
const getBarSnappedBeatFromClientX = (clientX: number) => {
const beat = getBeatFromClientX(clientX);
return clampStartBeat(Math.floor(beat / beatsPerBar) * beatsPerBar);
};
const getRenderedBeatState = (region: KGChordRegion) => (
previewBeats[region.getId()] ?? {
startBeat: region.getStartFromBeat(),
length: region.getLength(),
}
);
const getResizeEdgeFromMouseEvent = (
event: React.MouseEvent<HTMLDivElement, MouseEvent>
): ResizeEdge => {
const rect = event.currentTarget.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
if (offsetX <= REGION_EDGE_HITBOX_PX) {
return 'start';
}
if (rect.width - offsetX <= REGION_EDGE_HITBOX_PX) {
return 'end';
}
return null;
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isModifierKeyPressed(event)) {
setIsModifierPressed(true);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
if (!isModifierKeyPressed(event)) {
setIsModifierPressed(false);
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (!interactionRef.current) {
return;
}
const interaction = interactionRef.current;
const deltaX = event.clientX - interaction.initialMouseX;
if (Math.abs(deltaX) >= DRAG_THRESHOLD_PX) {
interaction.moved = true;
}
if (interaction.mode === 'drag') {
const beatDelta = Math.round(deltaX / beatWidth);
const nextStartBeat = clampStartBeat(interaction.initialStartBeat + beatDelta);
setPreviewBeats({
[interaction.regionId]: {
startBeat: nextStartBeat,
length: interaction.initialLength,
},
});
return;
}
if (interaction.mode === 'resize') {
const desiredBeat = getBeatFromClientX(
event.clientX,
interaction.resizeEdge === 'end' ? 'end' : 'start'
);
if (interaction.resizeEdge === 'start') {
const nextStartBeat = Math.min(desiredBeat, interaction.initialStartBeat + interaction.initialLength - 1);
const endBeat = interaction.initialStartBeat + interaction.initialLength;
setPreviewBeats({
[interaction.regionId]: {
startBeat: nextStartBeat,
length: Math.max(1, endBeat - nextStartBeat),
},
});
return;
}
setPreviewBeats({
[interaction.regionId]: {
startBeat: interaction.initialStartBeat,
length: Math.max(1, desiredBeat - interaction.initialStartBeat),
},
});
}
};
const handleMouseUp = (event: MouseEvent) => {
if (!interactionRef.current) {
return;
}
const interaction = interactionRef.current;
interactionRef.current = null;
if (!interaction.moved) {
setPreviewBeats({});
onSelectRegion(interaction.regionId, { shiftKey: event.shiftKey });
return;
}
const preview = previewBeats[interaction.regionId];
setPreviewBeats({});
if (!preview) {
return;
}
if (interaction.mode === 'drag') {
onMoveRegion(interaction.regionId, preview.startBeat);
return;
}
if (interaction.mode === 'resize' && interaction.resizeEdge) {
const beat = interaction.resizeEdge === 'start'
? preview.startBeat
: preview.startBeat + preview.length;
onResizeRegion(interaction.regionId, interaction.resizeEdge, beat);
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [beatWidth, onMoveRegion, onResizeRegion, onSelectRegion, previewBeats]);
const handleLaneMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
if (!(event.target instanceof HTMLElement)) {
return;
}
if (event.target.closest('.global-chord-region')) {
return;
}
if (!isModifierKeyPressed(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
};
const handleLaneDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (!(event.target instanceof HTMLElement)) {
return;
}
if (event.target.closest('.global-chord-region')) {
return;
}
event.preventDefault();
event.stopPropagation();
onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX));
};
return (
<div
ref={laneRef}
className={`global-marker-lane global-chord-lane${popupRegionId ? ' popup-open' : ''}${isModifierPressed ? ' pencil-cursor' : ''}`}
onMouseDown={handleLaneMouseDown}
onDoubleClick={handleLaneDoubleClick}
>
{chordRegions.map(region => {
const { startBeat, length } = getRenderedBeatState(region);
const isSelected = selectedRegionIds.includes(region.getId());
const left = startBeat * beatWidth;
const width = Math.max(beatWidth, length * beatWidth);
return (
<div
key={region.getId()}
className={`global-marker-region global-chord-region${isSelected ? ' selected' : ''}`}
style={{
left: `${left}px`,
width: `${width}px`,
cursor: hoverEdges[region.getId()] ? 'ew-resize' : undefined,
zIndex: popupRegionId === region.getId() ? 1006 : 1,
}}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
onSelectRegion(region.getId(), { shiftKey: false });
onOpenPopup(region.getId());
}}
onMouseMove={(event) => {
if (interactionRef.current?.regionId === region.getId()) {
return;
}
const resizeEdge = getResizeEdgeFromMouseEvent(event);
setHoverEdges((current) => (
current[region.getId()] === resizeEdge
? current
: {
...current,
[region.getId()]: resizeEdge,
}
));
}}
onMouseLeave={() => {
setHoverEdges((current) => {
if (!current[region.getId()]) {
return current;
}
return {
...current,
[region.getId()]: null,
};
});
}}
onMouseDown={(event) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
event.stopPropagation();
const resizeEdge = getResizeEdgeFromMouseEvent(event);
interactionRef.current = {
mode: resizeEdge ? 'resize' : 'drag',
regionId: region.getId(),
initialMouseX: event.clientX,
initialStartBeat: region.getStartFromBeat(),
initialLength: region.getLength(),
resizeEdge,
moved: false,
};
}}
onClick={(event) => {
event.stopPropagation();
onSelectRegion(region.getId(), { shiftKey: event.shiftKey });
}}
>
<span className="global-marker-label">{region.getSymbol()}</span>
<FloatingPopup
isOpen={popupRegionId === region.getId()}
onClose={onClosePopup}
placement="bottom"
className="global-chord-popup-anchor"
contentClassName="global-chord-popup-surface"
panelClassName="global-chord-popup-panel"
arrowClassName="global-chord-popup-arrow"
renderInPortal
trigger={<span className="global-chord-trigger" aria-hidden="true" />}
>
<ChordPickerPopup
value={region.getSymbol()}
onChange={(symbol) => onChangeChord(region.getId(), symbol)}
onTabNavigate={(direction) => onTabNavigate(region.getId(), direction)}
/>
</FloatingPopup>
</div>
);
})}
</div>
);
};
export default GlobalChordLane;
+1 -1
View File
@@ -58,7 +58,7 @@ export class KGProject {
@WithDefault(0)
private projectStructureVersion: number = 0;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 14;
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 15;
@Expose()
@Type(() => KGTrack, {
@@ -0,0 +1,68 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGChordRegion } from '../../region/KGChordRegion';
import { generateUniqueId } from '../../../util/miscUtil';
import { findGlobalTrackByType, findNonOverlappingNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
export class CreateChordRegionCommand extends KGCommand {
private readonly startBeat: number;
private readonly preferredLength: number;
private readonly symbol: string;
private readonly regionId: string;
private createdRegion: KGChordRegion | null = null;
constructor(startBeat: number, preferredLength: number, symbol: string = 'C', regionId?: string) {
super();
this.startBeat = startBeat;
this.preferredLength = preferredLength;
this.symbol = symbol;
this.regionId = regionId ?? generateUniqueId('KGChordRegion');
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord global track not found');
}
const { maxEndBeat } = findNonOverlappingNeighborBounds(project, GlobalTrackType.Chord, null, this.startBeat);
const songEndBeat = getSongEndBeat(project);
const allowedEndBeat = Math.min(maxEndBeat, songEndBeat);
const targetEndBeat = Math.min(this.startBeat + this.preferredLength, allowedEndBeat);
const length = Math.max(1, targetEndBeat - this.startBeat);
this.createdRegion = new KGChordRegion(
this.regionId,
chordTrack.getId(),
chordTrack.getTrackIndex(),
this.symbol,
this.startBeat,
length
);
chordTrack.setRegions(
[...chordTrack.getRegions(), this.createdRegion]
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord global track not found during undo');
}
chordTrack.removeRegion(this.regionId);
}
getDescription(): string {
return `Create chord "${this.symbol}"`;
}
public getCreatedRegion(): KGChordRegion | null {
return this.createdRegion;
}
}
@@ -49,7 +49,7 @@ export class DeleteGlobalRegionCommand extends KGCommand {
}
getDescription(): string {
return `Delete marker "${this.deletedRegion?.getName() ?? this.regionId}"`;
return `Delete global region "${this.deletedRegion?.getName() ?? this.regionId}"`;
}
}
@@ -104,6 +104,6 @@ export class DeleteMultipleGlobalRegionsCommand extends KGCommand {
}
getDescription(): string {
return this.regionIds.length === 1 ? 'Delete marker' : `Delete ${this.regionIds.length} markers`;
return this.regionIds.length === 1 ? 'Delete global region' : `Delete ${this.regionIds.length} global regions`;
}
}
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KGCore } from '../../KGCore';
import { KGProject } from '../../KGProject';
import { GlobalTrackType } from '../../global-track';
import { KGChordRegion } from '../../region/KGChordRegion';
import { CreateChordRegionCommand } from './CreateChordRegionCommand';
import { InsertChordRegionAtBeatCommand } from './InsertChordRegionAtBeatCommand';
import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
import { UpdateChordRegionCommand } from './UpdateChordRegionCommand';
describe('global chord region commands', () => {
beforeEach(() => {
const project = new KGProject('Chords', 8, 0, 120);
const mockCore = KGCore.instance() as unknown as {
getCurrentProject: ReturnType<typeof vi.fn>;
getSelectedItems: ReturnType<typeof vi.fn>;
removeSelectedItem?: ReturnType<typeof vi.fn>;
};
mockCore.getCurrentProject.mockReturnValue(project);
mockCore.getSelectedItems.mockReturnValue([]);
if (!mockCore.removeSelectedItem) {
mockCore.removeSelectedItem = vi.fn();
} else {
mockCore.removeSelectedItem.mockReset();
}
});
const getChordTrack = () => {
const chordTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
.find(track => track.getType() === GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord track missing in test setup');
}
return chordTrack;
};
it('creates a chord region with a default one-bar length clamped by the next region', () => {
const chordTrack = getChordTrack();
chordTrack.addRegion(new KGChordRegion('existing', chordTrack.getId(), chordTrack.getTrackIndex(), 'Fmaj7', 6, 2));
const command = new CreateChordRegionCommand(4, 4, 'Cmaj7');
command.execute();
const created = command.getCreatedRegion();
expect(created?.getStartFromBeat()).toBe(4);
expect(created?.getLength()).toBe(2);
});
it('moves and resizes chord regions with beat snapping and no overlap', () => {
const chordTrack = getChordTrack();
const region = new KGChordRegion('middle', chordTrack.getId(), chordTrack.getTrackIndex(), 'Dm7', 4, 2);
chordTrack.setRegions([
new KGChordRegion('left', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
region,
new KGChordRegion('right', chordTrack.getId(), chordTrack.getTrackIndex(), 'G7', 10, 2),
]);
const moveCommand = new MoveGlobalRegionCommand('middle', 9);
moveCommand.execute();
expect(region.getStartFromBeat()).toBe(8);
const resizeCommand = new ResizeGlobalRegionCommand('middle', 'end', 12);
resizeCommand.execute();
expect(region.getLength()).toBe(2);
const resizeMinCommand = new ResizeGlobalRegionCommand('middle', 'start', 9);
resizeMinCommand.execute();
expect(region.getStartFromBeat()).toBe(9);
expect(region.getLength()).toBe(1);
});
it('updates chord symbols with undo support', () => {
const chordTrack = getChordTrack();
const region = new KGChordRegion('chord', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4);
chordTrack.setRegions([region]);
const command = new UpdateChordRegionCommand('chord', 'Bm7b5');
command.execute();
expect(region.getSymbol()).toBe('Bm7b5');
command.undo();
expect(region.getSymbol()).toBe('C');
});
it('inserts a new chord inside an existing region and shortens the original', () => {
const chordTrack = getChordTrack();
const region = new KGChordRegion('chord', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8);
chordTrack.setRegions([region]);
const command = new InsertChordRegionAtBeatCommand(3, 'C');
command.execute();
const created = command.getCreatedRegion();
expect(created).not.toBeNull();
expect(region.getLength()).toBe(3);
expect(created?.getStartFromBeat()).toBe(3);
expect(created?.getLength()).toBe(5);
command.undo();
expect(chordTrack.getRegions()).toHaveLength(1);
expect(region.getLength()).toBe(8);
});
});
@@ -0,0 +1,86 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { KGChordRegion } from '../../region/KGChordRegion';
import { generateUniqueId } from '../../../util/miscUtil';
import { findChordRegionAtBeat, findGlobalTrackByType } from '../../../util/globalTrackUtil';
export class InsertChordRegionAtBeatCommand extends KGCommand {
private readonly insertBeat: number;
private readonly symbol: string;
private readonly regionId: string;
private createdRegion: KGChordRegion | null = null;
private targetRegionId: string | null = null;
private originalTargetLength = 0;
constructor(insertBeat: number, symbol: string = 'C', regionId?: string) {
super();
this.insertBeat = insertBeat;
this.symbol = symbol;
this.regionId = regionId ?? generateUniqueId('KGChordRegion');
}
execute(): void {
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack) {
throw new Error('Chord global track not found');
}
const occupiedRegion = findChordRegionAtBeat(project, this.insertBeat);
if (!occupiedRegion) {
throw new Error(`No chord region found at beat ${this.insertBeat}`);
}
const regionStart = occupiedRegion.getStartFromBeat();
const regionEnd = regionStart + occupiedRegion.getLength();
if (this.insertBeat <= regionStart || this.insertBeat >= regionEnd) {
throw new Error(`Cannot insert chord at beat ${this.insertBeat} without shrinking region below minimum length`);
}
this.targetRegionId = occupiedRegion.getId();
this.originalTargetLength = occupiedRegion.getLength();
occupiedRegion.setLength(this.insertBeat - regionStart);
this.createdRegion = new KGChordRegion(
this.regionId,
chordTrack.getId(),
chordTrack.getTrackIndex(),
this.symbol,
this.insertBeat,
regionEnd - this.insertBeat
);
chordTrack.setRegions(
[...chordTrack.getRegions(), this.createdRegion]
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat())
);
}
undo(): void {
const project = KGCore.instance().getCurrentProject();
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!chordTrack || !this.targetRegionId || !this.createdRegion) {
throw new Error('Cannot undo inserted chord region');
}
chordTrack.removeRegion(this.createdRegion.getId());
const targetRegion = chordTrack.getRegions().find((region): region is KGChordRegion => (
region instanceof KGChordRegion && region.getId() === this.targetRegionId
));
if (!targetRegion) {
throw new Error(`Chord region ${this.targetRegionId} not found during undo`);
}
targetRegion.setLength(this.originalTargetLength);
chordTrack.setRegions([...chordTrack.getRegions()].sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat()));
}
getDescription(): string {
return `Insert chord "${this.symbol}"`;
}
public getCreatedRegion(): KGChordRegion | null {
return this.createdRegion;
}
}
@@ -2,7 +2,7 @@ import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
import { GlobalTrackType } from '../../global-track';
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds } from '../../../util/globalTrackUtil';
import { findGlobalTrackContainingRegion, findNonOverlappingNeighborBounds } from '../../../util/globalTrackUtil';
export class MoveGlobalRegionCommand extends KGCommand {
private readonly regionId: string;
@@ -26,12 +26,17 @@ export class MoveGlobalRegionCommand extends KGCommand {
this.targetRegion = result.region;
this.originalStartBeat = result.region.getStartFromBeat();
if (result.track.getType() !== GlobalTrackType.Marker) {
if (result.track.getType() !== GlobalTrackType.Marker && result.track.getType() !== GlobalTrackType.Chord) {
result.region.setStartFromBeat(this.desiredStartBeat);
return;
}
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.desiredStartBeat);
const { minStartBeat, maxEndBeat } = findNonOverlappingNeighborBounds(
project,
result.track.getType() as GlobalTrackType.Marker | GlobalTrackType.Chord,
this.regionId,
this.desiredStartBeat
);
const maxStartBeat = Math.max(minStartBeat, maxEndBeat - result.region.getLength());
const clampedStartBeat = Math.max(minStartBeat, Math.min(this.desiredStartBeat, maxStartBeat));
result.region.setStartFromBeat(clampedStartBeat);
@@ -1,7 +1,7 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { GlobalTrackType } from '../../global-track';
import { findGlobalTrackContainingRegion, findMarkerNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
import { findGlobalTrackContainingRegion, findNonOverlappingNeighborBounds, getSongEndBeat } from '../../../util/globalTrackUtil';
import { KGGlobalRegion } from '../../region/KGGlobalRegion';
export type GlobalRegionResizeEdge = 'start' | 'end';
@@ -32,12 +32,17 @@ export class ResizeGlobalRegionCommand extends KGCommand {
this.originalStartBeat = result.region.getStartFromBeat();
this.originalLength = result.region.getLength();
if (result.track.getType() !== GlobalTrackType.Marker) {
if (result.track.getType() !== GlobalTrackType.Marker && result.track.getType() !== GlobalTrackType.Chord) {
return;
}
const originalEndBeat = this.originalStartBeat + this.originalLength;
const { minStartBeat, maxEndBeat } = findMarkerNeighborBounds(project, this.regionId, this.originalStartBeat);
const { minStartBeat, maxEndBeat } = findNonOverlappingNeighborBounds(
project,
result.track.getType() as GlobalTrackType.Marker | GlobalTrackType.Chord,
this.regionId,
this.originalStartBeat
);
const songEndBeat = getSongEndBeat(project);
const absoluteMaxEndBeat = Math.min(maxEndBeat, songEndBeat);
@@ -0,0 +1,39 @@
import { KGCommand } from '../KGCommand';
import { KGCore } from '../../KGCore';
import { findGlobalTrackContainingRegion } from '../../../util/globalTrackUtil';
import { KGChordRegion } from '../../region/KGChordRegion';
export class UpdateChordRegionCommand extends KGCommand {
private readonly regionId: string;
private readonly nextSymbol: string;
private previousSymbol: string | null = null;
constructor(regionId: string, nextSymbol: string) {
super();
this.regionId = regionId;
this.nextSymbol = nextSymbol;
}
execute(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result || !(result.region instanceof KGChordRegion)) {
throw new Error(`Chord region with ID ${this.regionId} not found`);
}
this.previousSymbol = result.region.getSymbol();
result.region.setSymbol(this.nextSymbol);
}
undo(): void {
const result = findGlobalTrackContainingRegion(KGCore.instance().getCurrentProject(), this.regionId);
if (!result || !(result.region instanceof KGChordRegion) || this.previousSymbol === null) {
throw new Error(`Chord region with ID ${this.regionId} not found during undo`);
}
result.region.setSymbol(this.previousSymbol);
}
getDescription(): string {
return `Change chord to "${this.nextSymbol}"`;
}
}
+3
View File
@@ -36,6 +36,8 @@ export { MergeMidiRegionsCommand } from './region/MergeMidiRegionsCommand';
// Global region commands
export { CreateGlobalMarkerRegionCommand } from './global-region/CreateGlobalMarkerRegionCommand';
export { CreateChordRegionCommand } from './global-region/CreateChordRegionCommand';
export { InsertChordRegionAtBeatCommand } from './global-region/InsertChordRegionAtBeatCommand';
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
@@ -44,6 +46,7 @@ export { ResizeKeySignatureRegionCommand } from './global-region/ResizeKeySignat
export { ResizeTempoRegionCommand } from './global-region/ResizeTempoRegionCommand';
export { DeleteGlobalRegionCommand, DeleteMultipleGlobalRegionsCommand } from './global-region/DeleteGlobalRegionCommand';
export { UpdateGlobalRegionTextCommand } from './global-region/UpdateGlobalRegionTextCommand';
export { UpdateChordRegionCommand } from './global-region/UpdateChordRegionCommand';
export { DeleteKeySignatureRegionCommand, DeleteMultipleKeySignatureRegionsCommand } from './global-region/DeleteKeySignatureRegionCommand';
export { UpdateKeySignatureRegionCommand } from './global-region/UpdateKeySignatureRegionCommand';
export { DeleteTempoRegionCommand, DeleteMultipleTempoRegionsCommand } from './global-region/DeleteTempoRegionCommand';
+2
View File
@@ -1,5 +1,6 @@
import { Expose, Type } from 'class-transformer';
import { KGGlobalRegion } from '../region/KGGlobalRegion';
import { KGChordRegion } from '../region/KGChordRegion';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
import { KGTempoRegion } from '../region/KGTempoRegion';
@@ -36,6 +37,7 @@ export class KGGlobalTrack {
{ value: KGMarkerRegion, name: 'KGMarkerRegion' },
{ value: KGTempoRegion, name: 'KGTempoRegion' },
{ value: KGKeySignatureRegion, name: 'KGKeySignatureRegion' },
{ value: KGChordRegion, name: 'KGChordRegion' },
],
},
})
+19
View File
@@ -4,6 +4,7 @@ import { KGProject } from '../KGProject';
import { GlobalTrackType } from '../global-track';
import { KGKeySignatureRegion } from '../region/KGKeySignatureRegion';
import { KGMarkerRegion } from '../region/KGMarkerRegion';
import { KGChordRegion } from '../region/KGChordRegion';
import { KGTrack } from '../track/KGTrack';
// --- OPFS mock infrastructure ---
@@ -199,6 +200,24 @@ describe('KGProjectStorage', () => {
expect((loadedSignatureTrack?.getRegions()[0] as KGKeySignatureRegion).getStartBar()).toBe(4);
});
it('preserves chord regions when saving and loading', async () => {
const project = createTestProject('Chord Song');
const chordTrack = project.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Chord);
expect(chordTrack).toBeDefined();
chordTrack?.addRegion(new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Bm7b5', 5, 3));
await storage.save('Chord Song', project);
const loaded = await storage.load('Chord Song');
const loadedChordTrack = loaded?.getGlobalTracks().find(track => track.getType() === GlobalTrackType.Chord);
expect(loadedChordTrack).toBeDefined();
expect(loadedChordTrack?.getRegions()).toHaveLength(1);
expect(loadedChordTrack?.getRegions()[0]).toBeInstanceOf(KGChordRegion);
expect((loadedChordTrack?.getRegions()[0] as KGChordRegion).getSymbol()).toBe('Bm7b5');
});
it('creates meta.json and media/ directory on save', async () => {
const project = createTestProject('My Song');
await storage.save('My Song', project);
@@ -13,6 +13,7 @@ import { upgradeToV11 } from './upgradeToV11';
import { upgradeToV12 } from './upgradeToV12';
import { upgradeToV13 } from './upgradeToV13';
import { upgradeToV14 } from './upgradeToV14';
import { upgradeToV15 } from './upgradeToV15';
/**
* Upgrade the given project to the latest structure version, one version at a time.
@@ -88,6 +89,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
workingProject = upgradeToV14(workingProject);
break;
}
case 15: {
workingProject = upgradeToV15(workingProject);
break;
}
default: {
// If an upgrader is missing, throw to prevent loading incompatible structures
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { KGProject } from '../KGProject';
import { upgradeProjectToLatest } from './KGProjectUpgrader';
import { upgradeToV15 } from './upgradeToV15';
describe('upgradeToV15', () => {
it('marks projects as upgraded and preserves default global tracks', () => {
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 14, 1, []);
upgradeToV15(project);
expect(project.getProjectStructureVersion()).toBe(15);
expect(project.getGlobalTracks()).toHaveLength(4);
});
it('runs through the main upgrader path', () => {
const project = new KGProject('Legacy', 32, 0, 125, undefined, undefined, undefined, undefined, [0, 0], 1, [], 14, 1, []);
const upgraded = upgradeProjectToLatest(project);
expect(upgraded.getProjectStructureVersion()).toBe(KGProject.CURRENT_PROJECT_STRUCTURE_VERSION);
});
});
+12
View File
@@ -0,0 +1,12 @@
import { KGProject } from '../KGProject';
import { ensureDefaultGlobalTracks } from '../../util/globalTrackUtil';
export function upgradeToV15(project: KGProject): KGProject {
try {
ensureDefaultGlobalTracks(project);
} finally {
project.setProjectStructureVersion(15);
}
return project;
}
+37
View File
@@ -0,0 +1,37 @@
import { Expose } from 'class-transformer';
import { KGGlobalRegion } from './KGGlobalRegion';
export class KGChordRegion extends KGGlobalRegion {
@Expose()
protected override __type: string = 'KGChordRegion';
@Expose()
private symbol: string = 'C';
constructor(
id: string,
trackId: string,
trackIndex: number,
symbol: string,
startFromBeat: number = 0,
length: number = 0
) {
super(id, trackId, trackIndex, symbol, startFromBeat, length);
this.__type = 'KGChordRegion';
this.symbol = symbol;
super.setName(symbol);
}
public getSymbol(): string {
return this.symbol;
}
public setSymbol(symbol: string): void {
this.symbol = symbol;
super.setName(symbol);
}
public override getCurrentType(): string {
return 'KGChordRegion';
}
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { buildChordSymbol, formatChordSymbolForDisplay, getChordMidiPitches, getChordPitchClasses, parseChordSymbol } from './chordUtil';
describe('chordUtil', () => {
it('parses half-diminished chords into the popup descriptor shape', () => {
const parsed = parseChordSymbol('Bm7b5');
expect(parsed).not.toBeNull();
expect(parsed?.root).toBe('B');
expect(parsed?.quality).toBe('dim');
expect(parsed?.extensions).toContain('b5');
expect(parsed?.extensions).toContain('7');
expect(parsed?.symbol).toBe('Bm7b5');
});
it('preserves enharmonic root spelling in the canonical symbol', () => {
expect(buildChordSymbol({
root: 'Bb',
quality: 'maj',
extensions: ['7'],
})).toBe('Bb7');
expect(buildChordSymbol({
root: 'A#',
quality: 'maj',
extensions: ['7'],
})).toBe('A#7');
});
it('rejects unsupported symbols deterministically', () => {
expect(parseChordSymbol('C/E')).toBeNull();
expect(parseChordSymbol('not-a-chord')).toBeNull();
});
it('derives stable pitch classes and midi pitches from the stored symbol', () => {
expect(getChordPitchClasses('Bm7b5')).toEqual([11, 2, 5, 9]);
expect(getChordMidiPitches('Bm7b5', 59)).toEqual([59, 62, 65, 69]);
});
it('formats the preview using standard chord display conventions', () => {
expect(formatChordSymbolForDisplay('Bm7b5')).toBe('Bm7(♭5)');
expect(formatChordSymbolForDisplay('Bbmaj7#11')).toBe('B♭maj7(♯11)');
});
});
+550
View File
@@ -0,0 +1,550 @@
import { Chord, Interval, Note } from 'tonal';
export type ChordQuality = 'maj' | 'min' | 'sus2' | 'sus4' | 'power' | 'aug' | 'dim';
export type ChordExtension =
| 'b5'
| '#5'
| '6'
| '7'
| 'maj7'
| 'b9'
| '9'
| '#9'
| '11'
| '#11'
| 'b13'
| '13';
export interface ChordDescriptor {
root: string;
quality: ChordQuality;
extensions: ChordExtension[];
symbol: string;
}
const ROOT_PATTERN = /^[A-G](?:#|b)?$/;
const EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', '6', '7', 'maj7', 'b9', '9', '#9', '11', '#11', 'b13', '13'];
const REMAINING_EXTENSION_ORDER: ChordExtension[] = ['b5', '#5', 'b9', '9', '#9', '11', '#11', 'b13', '13'];
const ADD_EXTENSION_ORDER: ChordExtension[] = ['b9', '9', '#9', '11', '#11', 'b13', '13'];
const CUSTOM_TOKENS = [
'maj7#5',
'm7b5',
'sus2',
'sus4',
'aug',
'dim',
'maj7',
'add#11',
'addb13',
'add13',
'add11',
'add#9',
'addb9',
'add9',
'#11',
'b13',
'#9',
'b9',
'9',
'13',
'11',
'#5',
'b5',
'7',
'6',
'm',
'5',
] as const;
function normalizeRoot(root: string): string | null {
const normalized = root.trim();
if (!ROOT_PATTERN.test(normalized)) {
return null;
}
const parsed = Note.get(normalized);
return parsed.empty || parsed.acc.length > 1 ? null : parsed.pc;
}
function sortExtensions(extensions: Iterable<ChordExtension>): ChordExtension[] {
const unique = new Set(extensions);
return EXTENSION_ORDER.filter(extension => unique.has(extension));
}
function hasExtension(descriptor: Pick<ChordDescriptor, 'extensions'>, extension: ChordExtension): boolean {
return descriptor.extensions.includes(extension);
}
function getDescriptorIntervals(descriptor: Pick<ChordDescriptor, 'quality' | 'extensions'>): string[] {
const intervals = ['1P'];
switch (descriptor.quality) {
case 'maj':
intervals.push('3M', '5P');
break;
case 'min':
intervals.push('3m', '5P');
break;
case 'sus2':
intervals.push('2M', '5P');
break;
case 'sus4':
intervals.push('4P', '5P');
break;
case 'power':
intervals.push('5P');
break;
case 'aug':
intervals.push('3M', '5A');
break;
case 'dim':
intervals.push('3m', '5d');
break;
}
for (const extension of descriptor.extensions) {
switch (extension) {
case 'b5':
if (!intervals.includes('5d')) {
intervals.push('5d');
}
break;
case '#5':
if (!intervals.includes('5A')) {
intervals.push('5A');
}
break;
case '6':
intervals.push('6M');
break;
case '7':
intervals.push('7m');
break;
case 'maj7':
intervals.push('7M');
break;
case 'b9':
intervals.push('9m');
break;
case '9':
intervals.push('9M');
break;
case '#9':
intervals.push('9A');
break;
case '11':
intervals.push('11P');
break;
case '#11':
intervals.push('11A');
break;
case 'b13':
intervals.push('13m');
break;
case '13':
intervals.push('13M');
break;
}
}
return Array.from(new Set(intervals));
}
function parseIntervalsToDescriptor(root: string, intervals: string[]): ChordDescriptor | null {
const intervalSet = new Set(intervals);
let quality: ChordQuality | null = null;
if (intervalSet.has('5A')) {
quality = 'aug';
} else if (intervalSet.has('2M') && !intervalSet.has('3m') && !intervalSet.has('3M') && !intervalSet.has('4P') && intervalSet.has('5P')) {
quality = 'sus2';
} else if (intervalSet.has('4P') && !intervalSet.has('3m') && !intervalSet.has('3M') && intervalSet.has('5P')) {
quality = 'sus4';
} else if (intervalSet.has('3m') && intervalSet.has('5d')) {
quality = 'dim';
} else if (intervalSet.has('3m')) {
quality = 'min';
} else if (intervalSet.has('3M')) {
quality = 'maj';
} else if (intervalSet.has('5P')) {
quality = 'power';
}
if (!quality) {
return null;
}
if (intervalSet.has('7d') || intervalSet.has('3A') || intervalSet.has('4d')) {
return null;
}
const extensions = new Set<ChordExtension>();
if (intervalSet.has('5d')) {
extensions.add('b5');
}
if (intervalSet.has('5A')) {
extensions.add('#5');
}
if (intervalSet.has('6M')) {
extensions.add('6');
}
if (intervalSet.has('7m')) {
extensions.add('7');
}
if (intervalSet.has('7M')) {
extensions.add('maj7');
}
if (intervalSet.has('9m') || intervalSet.has('2m')) {
extensions.add('b9');
}
if (intervalSet.has('9M') || (intervalSet.has('2M') && quality !== 'sus2')) {
extensions.add('9');
}
if (intervalSet.has('9A')) {
extensions.add('#9');
}
if (intervalSet.has('11P') && quality !== 'sus4') {
extensions.add('11');
}
if (intervalSet.has('11A')) {
extensions.add('#11');
}
if (intervalSet.has('13m') || intervalSet.has('6m')) {
extensions.add('b13');
}
if (intervalSet.has('13M')) {
extensions.add('13');
}
const descriptor: ChordDescriptor = {
root,
quality,
extensions: sortExtensions(extensions),
symbol: '',
};
const symbol = buildChordSymbol(descriptor);
if (!symbol) {
return null;
}
return {
...descriptor,
symbol,
};
}
function parseCustomChordSymbol(symbol: string): ChordDescriptor | null {
const trimmed = symbol.trim();
const rootMatch = trimmed.match(/^([A-G](?:#|b)?)(.*)$/);
if (!rootMatch) {
return null;
}
const root = normalizeRoot(rootMatch[1]);
if (!root) {
return null;
}
let remainder = rootMatch[2];
let quality: ChordQuality = 'maj';
const extensions = new Set<ChordExtension>();
if (remainder.startsWith('m7b5')) {
quality = 'dim';
extensions.add('b5');
extensions.add('7');
remainder = remainder.slice(4);
} else if (remainder.startsWith('maj7#5')) {
quality = 'aug';
extensions.add('#5');
extensions.add('maj7');
remainder = remainder.slice(6);
} else if (remainder.startsWith('7#5')) {
quality = 'aug';
extensions.add('#5');
extensions.add('7');
remainder = remainder.slice(3);
} else if (remainder.startsWith('sus2')) {
quality = 'sus2';
remainder = remainder.slice(4);
} else if (remainder.startsWith('sus4')) {
quality = 'sus4';
remainder = remainder.slice(4);
} else if (remainder.startsWith('aug')) {
quality = 'aug';
remainder = remainder.slice(3);
} else if (remainder.startsWith('dim')) {
quality = 'dim';
remainder = remainder.slice(3);
} else if (remainder.startsWith('m')) {
quality = 'min';
remainder = remainder.slice(1);
} else if (remainder.startsWith('5')) {
quality = 'power';
remainder = remainder.slice(1);
}
if (remainder.startsWith('maj7')) {
extensions.add('maj7');
remainder = remainder.slice(4);
} else if (remainder.startsWith('7')) {
extensions.add('7');
remainder = remainder.slice(1);
} else if (remainder.startsWith('6')) {
extensions.add('6');
remainder = remainder.slice(1);
}
while (remainder.length > 0) {
const nextToken = CUSTOM_TOKENS.find(token => remainder.startsWith(token));
if (!nextToken) {
return null;
}
switch (nextToken) {
case 'b5':
extensions.add('b5');
break;
case '#5':
extensions.add('#5');
break;
case 'b9':
case 'addb9':
extensions.add('b9');
break;
case '9':
case 'add9':
extensions.add('9');
break;
case '#9':
case 'add#9':
extensions.add('#9');
break;
case '11':
case 'add11':
extensions.add('11');
break;
case '#11':
case 'add#11':
extensions.add('#11');
break;
case 'b13':
case 'addb13':
extensions.add('b13');
break;
case '13':
case 'add13':
extensions.add('13');
break;
default:
return null;
}
remainder = remainder.slice(nextToken.length);
}
const descriptor: ChordDescriptor = {
root,
quality,
extensions: sortExtensions(extensions),
symbol: '',
};
const canonical = buildChordSymbol(descriptor);
if (!canonical) {
return null;
}
return {
...descriptor,
symbol: canonical,
};
}
function tokenizeTonalIntervals(intervals: string[]): string[] | null {
const normalized = intervals
.map(interval => interval.replace(/^(\d+)([PmMdA])$/, '$1$2').replace(/^(\d+)([dm])$/, '$1$2'))
.filter(Boolean);
if (normalized.length === 0) {
return null;
}
return normalized;
}
export function buildChordSymbol(descriptor: Pick<ChordDescriptor, 'root' | 'quality' | 'extensions'>): string | null {
const root = normalizeRoot(descriptor.root);
if (!root) {
return null;
}
const extensions = sortExtensions(descriptor.extensions);
const has = (extension: ChordExtension) => extensions.includes(extension);
const hasSeventh = has('7') || has('maj7');
const remainingExtensions = new Set(extensions);
let symbol = root;
if (descriptor.quality === 'dim' && has('7')) {
symbol += 'm7b5';
remainingExtensions.delete('7');
remainingExtensions.delete('b5');
} else if (descriptor.quality === 'aug' && has('7')) {
symbol += '7#5';
remainingExtensions.delete('7');
remainingExtensions.delete('#5');
} else if (descriptor.quality === 'aug' && has('maj7')) {
symbol += 'maj7#5';
remainingExtensions.delete('maj7');
remainingExtensions.delete('#5');
} else {
switch (descriptor.quality) {
case 'maj':
break;
case 'min':
symbol += 'm';
break;
case 'sus2':
symbol += 'sus2';
break;
case 'sus4':
symbol += 'sus4';
break;
case 'power':
symbol += '5';
break;
case 'aug':
symbol += 'aug';
remainingExtensions.delete('#5');
break;
case 'dim':
symbol += 'dim';
remainingExtensions.delete('b5');
break;
}
if (has('maj7')) {
symbol += 'maj7';
remainingExtensions.delete('maj7');
} else if (has('7')) {
symbol += '7';
remainingExtensions.delete('7');
} else if (has('6')) {
symbol += '6';
remainingExtensions.delete('6');
}
}
for (const extension of REMAINING_EXTENSION_ORDER) {
if (!remainingExtensions.has(extension)) {
continue;
}
if (ADD_EXTENSION_ORDER.includes(extension) && !hasSeventh) {
symbol += `add${extension}`;
} else {
symbol += extension;
}
}
return symbol;
}
export function parseChordSymbol(symbol: string): ChordDescriptor | null {
const trimmed = symbol.trim();
if (!trimmed) {
return null;
}
const tonalChord = Chord.get(trimmed);
if (tonalChord && !tonalChord.empty && tonalChord.tonic && !tonalChord.bass && !tonalChord.root) {
const root = normalizeRoot(tonalChord.tonic);
const intervals = tokenizeTonalIntervals(tonalChord.intervals);
if (root && intervals) {
const parsed = parseIntervalsToDescriptor(root, intervals);
if (parsed) {
return parsed;
}
}
}
return parseCustomChordSymbol(trimmed);
}
export function getChordPitchClasses(symbol: string): number[] {
const descriptor = parseChordSymbol(symbol);
if (!descriptor) {
return [];
}
const root = Note.get(descriptor.root);
if (root.empty || root.chroma === undefined) {
return [];
}
return getDescriptorIntervals(descriptor).map((interval) => {
const semitones = Interval.semitones(interval);
return (root.chroma + semitones + 120) % 12;
});
}
export function getChordMidiPitches(symbol: string, rootMidi: number): number[] {
const descriptor = parseChordSymbol(symbol);
if (!descriptor) {
return [];
}
return getDescriptorIntervals(descriptor).map((interval) => rootMidi + Interval.semitones(interval));
}
export function formatChordSymbolForDisplay(symbol: string): string {
const descriptor = parseChordSymbol(symbol);
if (!descriptor) {
return symbol;
}
const { root, quality, extensions } = descriptor;
const accidentalDisplay = (value: string) => value.replace(/b/g, '♭').replace(/#/g, '♯');
const baseQuality = (() => {
switch (quality) {
case 'maj':
return '';
case 'min':
return 'm';
case 'sus2':
return 'sus2';
case 'sus4':
return 'sus4';
case 'power':
return '5';
case 'aug':
return 'aug';
case 'dim':
return 'm';
}
})();
const inlineExtensions: string[] = [];
const parentheticalExtensions: string[] = [];
for (const extension of extensions) {
if (extension === '7' || extension === 'maj7' || extension === '6') {
inlineExtensions.push(extension);
continue;
}
parentheticalExtensions.push(accidentalDisplay(extension));
}
const inlineText = inlineExtensions.join('');
const parentheticalText = parentheticalExtensions.length > 0
? `(${parentheticalExtensions.join(', ')})`
: '';
return `${accidentalDisplay(root)}${baseQuality}${inlineText}${parentheticalText}`;
}
export const CHORD_QUALITY_OPTIONS: ChordQuality[] = ['maj', 'min', 'sus2', 'sus4', 'power', 'aug', 'dim'];
export const CHORD_EXTENSION_OPTIONS: ChordExtension[] = EXTENSION_ORDER;
+24 -3
View File
@@ -6,6 +6,7 @@ import {
createDefaultGlobalTracks,
} from '../core/global-track';
import { KGGlobalRegion } from '../core/region/KGGlobalRegion';
import { KGChordRegion } from '../core/region/KGChordRegion';
import { KGKeySignatureRegion } from '../core/region/KGKeySignatureRegion';
import { KGAudioRegion } from '../core/region/KGAudioRegion';
import { KGTempoRegion } from '../core/region/KGTempoRegion';
@@ -59,14 +60,23 @@ export function findMarkerNeighborBounds(
regionId: string | null,
proposedStartBeat: number
): { minStartBeat: number; maxEndBeat: number; nextStartBeat: number | null } {
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
return findNonOverlappingNeighborBounds(project, GlobalTrackType.Marker, regionId, proposedStartBeat);
}
export function findNonOverlappingNeighborBounds(
project: KGProject,
trackType: GlobalTrackType.Marker | GlobalTrackType.Chord,
regionId: string | null,
proposedStartBeat: number
): { minStartBeat: number; maxEndBeat: number; nextStartBeat: number | null } {
const track = findGlobalTrackByType(project, trackType);
const songEndBeat = getSongEndBeat(project);
if (!markerTrack) {
if (!track) {
return { minStartBeat: 0, maxEndBeat: songEndBeat, nextStartBeat: null };
}
const otherRegions = markerTrack.getRegions()
const otherRegions = track.getRegions()
.filter(region => region.getId() !== regionId)
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
@@ -88,6 +98,17 @@ export function findMarkerNeighborBounds(
return { minStartBeat, maxEndBeat, nextStartBeat };
}
export function findChordRegionAtBeat(project: KGProject, beat: number): KGChordRegion | null {
const track = findGlobalTrackByType(project, GlobalTrackType.Chord);
if (!track) {
return null;
}
return track.getRegions()
.filter((region): region is KGChordRegion => region instanceof KGChordRegion)
.find(region => beat >= region.getStartFromBeat() && beat < region.getStartFromBeat() + region.getLength()) ?? null;
}
export function getSongEndBar(project: KGProject): number {
return project.getMaxBars();
}