diff --git a/package-lock.json b/package-lock.json index b15f8ea..a51d016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c0eb472..9cde4bc 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/ChordPickerPopup.css b/src/components/ChordPickerPopup.css new file mode 100644 index 0000000..3c68a82 --- /dev/null +++ b/src/components/ChordPickerPopup.css @@ -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)); + } +} diff --git a/src/components/ChordPickerPopup.test.tsx b/src/components/ChordPickerPopup.test.tsx new file mode 100644 index 0000000..734ca8d --- /dev/null +++ b/src/components/ChordPickerPopup.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab', shiftKey: true }); + + expect(onTabNavigate).toHaveBeenCalledWith('backward'); + }); + + it('focuses the chord input when the popup opens', () => { + render(); + + expect(screen.getByRole('textbox')).toHaveFocus(); + }); + + it('blocks tab navigation when the current input is invalid', () => { + const onChange = vi.fn(); + const onTabNavigate = vi.fn(); + + render(); + + 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(); + }); +}); diff --git a/src/components/ChordPickerPopup.tsx b/src/components/ChordPickerPopup.tsx new file mode 100644 index 0000000..68c1f9f --- /dev/null +++ b/src/components/ChordPickerPopup.tsx @@ -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 = { + maj: 'Maj', + min: 'Min', + sus2: 'Sus2', + sus4: 'Sus4', + power: '5', + aug: 'Aug', + dim: 'Dim', +}; + +const EXTENSION_LABELS: Record = { + 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 = ({ value, onChange, onTabNavigate }) => { + const [descriptor, setDescriptor] = React.useState(() => parseChordSymbol(value) ?? createFallbackDescriptor(value)); + const [inputText, setInputText] = React.useState(value); + const [error, setError] = React.useState(null); + const [showRootDropdown, setShowRootDropdown] = React.useState(false); + const inputRef = React.useRef(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 ( +
{ + if (event.key === 'Tab' && onTabNavigate) { + event.preventDefault(); + event.stopPropagation(); + const isValid = handleSubmit(); + if (!isValid) { + return; + } + + onTabNavigate(event.shiftKey ? 'backward' : 'forward'); + } + }} + > +
+ +
+ +
+
+ Root Note + applyDescriptor({ ...descriptor, root: nextRoot })} + label="Root Note" + showValueAsLabel={true} + isOpen={showRootDropdown} + onToggle={setShowRootDropdown} + buttonClassName="chord-picker-root-button" + className="chord-picker-root-dropdown" + /> +
+
+ +
+ {QUALITY_ROWS.map((row, rowIndex) => ( +
+ {row.map((quality) => ( + + ))} +
+ ))} +
+ +
+ {EXTENSION_ROWS.map((row, rowIndex) => ( +
+ {row.map((extension) => ( + + ))} +
+ ))} +
+ +
+ {formatChordSymbolForDisplay(descriptor.symbol || value)} + {error && {error}} +
+
+ ); +}; + +export default ChordPickerPopup; diff --git a/src/components/MainContent.css b/src/components/MainContent.css index 78a5653..3c60c29 100644 --- a/src/components/MainContent.css +++ b/src/components/MainContent.css @@ -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; diff --git a/src/components/MainContent.test.tsx b/src/components/MainContent.test.tsx index 21d50d5..f280c18 100644 --- a/src/components/MainContent.test.tsx +++ b/src/components/MainContent.test.tsx @@ -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(); 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(); + + 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(); + + 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(); + + 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(); + + 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', () => { diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 7fe7a57..bc67358 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -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 = ({ const [editingKeySignatureRegionId, setEditingKeySignatureRegionId] = useState(null); const [editingTempoRegionId, setEditingTempoRegionId] = useState(null); const [editingTempoText, setEditingTempoText] = useState(''); + const [editingChordRegionId, setEditingChordRegionId] = useState(null); // Use the region operations hook const { deleteSelectedRegions } = useRegionOperations({ @@ -186,6 +192,10 @@ const MainContent: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ } }, [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 = ({ if (track.id === 'signature') { createKeySignatureAtPlayheadBar(); + return; + } + + if (track.id === 'chord') { + createChordAtPlayheadBeat(); } }} > @@ -1590,6 +1755,24 @@ const MainContent: React.FC = ({ onChangeKeySignature={updateKeySignatureRegion} onOpenPicker={beginEditingKeySignatureRegion} /> + ) : track.id === 'chord' ? ( + setEditingChordRegionId(null)} + onSelectRegion={selectGlobalRegion} + onCreateAtBeat={createChordAtBeat} + onMoveRegion={moveGlobalChordRegion} + onResizeRegion={resizeGlobalChordRegion} + onChangeChord={updateChordRegion} + onOpenPopup={beginEditingChordRegion} + onTabNavigate={navigateChordPopupByBar} + /> ) : (
{ 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( + Toggle} + > +
Popup body
+
+ ); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(outerEscapeHandler).not.toHaveBeenCalled(); + + window.removeEventListener('keydown', outerEscapeHandler); + }); }); diff --git a/src/components/common/FloatingPopup.tsx b/src/components/common/FloatingPopup.tsx index 0e98476..f9fef18 100644 --- a/src/components/common/FloatingPopup.tsx +++ b/src/components/common/FloatingPopup.tsx @@ -33,6 +33,7 @@ const FloatingPopup: React.FC = ({ }) => { const rootRef = React.useRef(null); const triggerRef = React.useRef(null); + const surfaceRef = React.useRef(null); const [portalStyle, setPortalStyle] = React.useState(undefined); React.useLayoutEffect(() => { @@ -93,33 +94,44 @@ const FloatingPopup: React.FC = ({ } 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 ? (
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} >
{ + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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); + }); +}); diff --git a/src/components/global-track/GlobalChordLane.tsx b/src/components/global-track/GlobalChordLane.tsx new file mode 100644 index 0000000..8de3611 --- /dev/null +++ b/src/components/global-track/GlobalChordLane.tsx @@ -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 = ({ + chordRegions, + maxBars, + barWidthMultiplier, + timeSignature, + selectedRegionIds, + popupRegionId, + onClosePopup, + onSelectRegion, + onCreateAtBeat, + onMoveRegion, + onResizeRegion, + onChangeChord, + onOpenPopup, + onTabNavigate, +}) => { + const laneRef = useRef(null); + const [previewBeats, setPreviewBeats] = useState>({}); + const [hoverEdges, setHoverEdges] = useState>({}); + 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 + ): 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) => { + 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) => { + if (!(event.target instanceof HTMLElement)) { + return; + } + if (event.target.closest('.global-chord-region')) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onCreateAtBeat(getBarSnappedBeatFromClientX(event.clientX)); + }; + + return ( +
+ {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 ( +
{ + 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 }); + }} + > + {region.getSymbol()} + +
+ ); + })} +
+ ); +}; + +export default GlobalChordLane; diff --git a/src/core/KGProject.ts b/src/core/KGProject.ts index bb0b2d2..5a7ad45 100644 --- a/src/core/KGProject.ts +++ b/src/core/KGProject.ts @@ -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, { diff --git a/src/core/commands/global-region/CreateChordRegionCommand.ts b/src/core/commands/global-region/CreateChordRegionCommand.ts new file mode 100644 index 0000000..ecbfe51 --- /dev/null +++ b/src/core/commands/global-region/CreateChordRegionCommand.ts @@ -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; + } +} diff --git a/src/core/commands/global-region/DeleteGlobalRegionCommand.ts b/src/core/commands/global-region/DeleteGlobalRegionCommand.ts index 674fddf..c622547 100644 --- a/src/core/commands/global-region/DeleteGlobalRegionCommand.ts +++ b/src/core/commands/global-region/DeleteGlobalRegionCommand.ts @@ -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`; } } diff --git a/src/core/commands/global-region/GlobalChordCommands.test.ts b/src/core/commands/global-region/GlobalChordCommands.test.ts new file mode 100644 index 0000000..b03480d --- /dev/null +++ b/src/core/commands/global-region/GlobalChordCommands.test.ts @@ -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; + getSelectedItems: ReturnType; + removeSelectedItem?: ReturnType; + }; + + 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); + }); +}); diff --git a/src/core/commands/global-region/InsertChordRegionAtBeatCommand.ts b/src/core/commands/global-region/InsertChordRegionAtBeatCommand.ts new file mode 100644 index 0000000..5542705 --- /dev/null +++ b/src/core/commands/global-region/InsertChordRegionAtBeatCommand.ts @@ -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; + } +} diff --git a/src/core/commands/global-region/MoveGlobalRegionCommand.ts b/src/core/commands/global-region/MoveGlobalRegionCommand.ts index a2b2a3b..0b0c12a 100644 --- a/src/core/commands/global-region/MoveGlobalRegionCommand.ts +++ b/src/core/commands/global-region/MoveGlobalRegionCommand.ts @@ -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); diff --git a/src/core/commands/global-region/ResizeGlobalRegionCommand.ts b/src/core/commands/global-region/ResizeGlobalRegionCommand.ts index f0294bf..6c0cada 100644 --- a/src/core/commands/global-region/ResizeGlobalRegionCommand.ts +++ b/src/core/commands/global-region/ResizeGlobalRegionCommand.ts @@ -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); diff --git a/src/core/commands/global-region/UpdateChordRegionCommand.ts b/src/core/commands/global-region/UpdateChordRegionCommand.ts new file mode 100644 index 0000000..3a92ffe --- /dev/null +++ b/src/core/commands/global-region/UpdateChordRegionCommand.ts @@ -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}"`; + } +} diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts index 1c2fcc3..2fe9532 100644 --- a/src/core/commands/index.ts +++ b/src/core/commands/index.ts @@ -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'; diff --git a/src/core/global-track/KGGlobalTrack.ts b/src/core/global-track/KGGlobalTrack.ts index c6d549b..9d63d16 100644 --- a/src/core/global-track/KGGlobalTrack.ts +++ b/src/core/global-track/KGGlobalTrack.ts @@ -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' }, ], }, }) diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts index 17c1fc0..af8dba8 100644 --- a/src/core/io/KGProjectStorage.test.ts +++ b/src/core/io/KGProjectStorage.test.ts @@ -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); diff --git a/src/core/project-upgrader/KGProjectUpgrader.ts b/src/core/project-upgrader/KGProjectUpgrader.ts index 47311b7..09e4fad 100644 --- a/src/core/project-upgrader/KGProjectUpgrader.ts +++ b/src/core/project-upgrader/KGProjectUpgrader.ts @@ -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}`); diff --git a/src/core/project-upgrader/upgradeToV15.test.ts b/src/core/project-upgrader/upgradeToV15.test.ts new file mode 100644 index 0000000..aac8da6 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV15.test.ts @@ -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); + }); +}); diff --git a/src/core/project-upgrader/upgradeToV15.ts b/src/core/project-upgrader/upgradeToV15.ts new file mode 100644 index 0000000..a78cd56 --- /dev/null +++ b/src/core/project-upgrader/upgradeToV15.ts @@ -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; +} diff --git a/src/core/region/KGChordRegion.ts b/src/core/region/KGChordRegion.ts new file mode 100644 index 0000000..d544606 --- /dev/null +++ b/src/core/region/KGChordRegion.ts @@ -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'; + } +} diff --git a/src/util/chordUtil.test.ts b/src/util/chordUtil.test.ts new file mode 100644 index 0000000..9dba207 --- /dev/null +++ b/src/util/chordUtil.test.ts @@ -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)'); + }); +}); diff --git a/src/util/chordUtil.ts b/src/util/chordUtil.ts new file mode 100644 index 0000000..f8607da --- /dev/null +++ b/src/util/chordUtil.ts @@ -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[] { + const unique = new Set(extensions); + return EXTENSION_ORDER.filter(extension => unique.has(extension)); +} + +function hasExtension(descriptor: Pick, extension: ChordExtension): boolean { + return descriptor.extensions.includes(extension); +} + +function getDescriptorIntervals(descriptor: Pick): 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(); + 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(); + + 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): 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; diff --git a/src/util/globalTrackUtil.ts b/src/util/globalTrackUtil.ts index cacbc02..41d82a7 100644 --- a/src/util/globalTrackUtil.ts +++ b/src/util/globalTrackUtil.ts @@ -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(); }