Merge pull request #29 from KGAudioLab/feat/2026-04-08-refactor-data-storage
Feat/2026 04 08 refactor data storage
This commit is contained in:
@@ -242,7 +242,7 @@ Feature priorities might change.
|
||||
- [ ] Support WAV audio tracks
|
||||
- [ ] Filters and effects
|
||||
- [ ] MCP Support
|
||||
- [ ] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`)
|
||||
- [X] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`)
|
||||
- [ ] Automatically compact conversations when the context window runs low on space
|
||||
|
||||
## Help Needed
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ export default tseslint.config([
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'warn', // Downgrade from error to warning
|
||||
'no-unused-vars': 'warn' // Also set the base rule to warn
|
||||
'no-unused-vars': 'warn', // Also set the base rule to warn
|
||||
'semi': ['error', 'always'],
|
||||
}
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+42
-10
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "K.G.Studio",
|
||||
"version": "0.8.0-build.20260123",
|
||||
"version": "0.9.0-build.20260406",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "K.G.Studio",
|
||||
"version": "0.8.0-build.20260123",
|
||||
"version": "0.9.0-build.20260406",
|
||||
"dependencies": {
|
||||
"class-transformer": "^0.5.1",
|
||||
"idb": "^8.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"openai": "^6.33.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -3805,7 +3806,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
@@ -5387,6 +5387,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -5441,7 +5447,6 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
@@ -5656,7 +5661,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
@@ -5931,6 +5935,18 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5965,6 +5981,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -11030,6 +11055,12 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -11377,7 +11408,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
@@ -11792,7 +11822,6 @@
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
@@ -12156,7 +12185,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
@@ -12529,6 +12557,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -12836,7 +12870,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
@@ -13674,7 +13707,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"class-transformer": "^0.5.1",
|
||||
"idb": "^8.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"openai": "^6.33.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
||||
-2026
File diff suppressed because it is too large
Load Diff
+23
-3
@@ -1,10 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import './App.css';
|
||||
import './styles/shared.css';
|
||||
import { useProjectStore } from './stores/projectStore';
|
||||
import { useGlobalKeyboardHandler } from './hooks/useGlobalKeyboardHandler';
|
||||
import Toolbar from './components/Toolbar';
|
||||
import StatusBar from './components/StatusBar';
|
||||
import TrackControl from './components/TrackControl';
|
||||
import MainContent from './components/MainContent';
|
||||
import InstrumentSelection from './components/InstrumentSelection';
|
||||
import ChatBox from './components/ChatBox';
|
||||
@@ -135,14 +135,15 @@ function App() {
|
||||
<ChatBox isVisible={showChatBox && !showSettings} />
|
||||
</div>
|
||||
|
||||
{/* Track Control */}
|
||||
<TrackControl />
|
||||
|
||||
{/* Status Bar */}
|
||||
<StatusBar />
|
||||
|
||||
{/* Global Loading Overlay for instrument buffer loading */}
|
||||
<GlobalLoadingOverlayContainer />
|
||||
|
||||
{/* Migration Loading Overlay */}
|
||||
<MigrationOverlayContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -212,3 +213,22 @@ const GlobalLoadingOverlayContainer: React.FC = () => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Migration overlay — shown during one-time IndexedDB -> OPFS migration
|
||||
const MigrationOverlayContainer: React.FC = () => {
|
||||
const [isMigrating, setIsMigrating] = useState<boolean>(() => KGCore.instance().getIsMigrating());
|
||||
|
||||
useEffectReact(() => {
|
||||
KGCore.instance().setMigrationStateChangeCallback(setIsMigrating);
|
||||
return () => {
|
||||
KGCore.instance().setMigrationStateChangeCallback(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LoadingOverlay
|
||||
visible={isMigrating}
|
||||
message="Migrating projects to new storage..."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/* ChatBox */
|
||||
.chatbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--chat-box-width);
|
||||
background-color: #2d2d2d;
|
||||
border-left: 1px solid #3a3a3a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chatbox.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chatbox-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; /* left title, right actions */
|
||||
background-color: #3a3a3a;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.chatbox-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chatbox-action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chatbox-action-btn:hover {
|
||||
background-color: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ChatBox export button wrapper and dropdown positioning */
|
||||
.chatbox-export-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.chatbox-export-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chatbox-export-dropdown-anchor {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.chatbox-export-dropdown .quant-dropdown {
|
||||
width: 250px;
|
||||
left: -200px;
|
||||
}
|
||||
|
||||
.chatbox-header h3 {
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chatbox-content {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Chat Messages */
|
||||
.chatbox-messages {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-user {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-assistant {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* User message styling - lighter background like input */
|
||||
.message-user .message-content {
|
||||
background-color: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
/* Assistant message styling - transparent */
|
||||
.message-assistant .message-content {
|
||||
background-color: transparent;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.chatbox-input-area {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.chatbox-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background-color: #4a4a4a;
|
||||
border: 1px solid #5a5a5a;
|
||||
border-radius: 6px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
min-height: 36px;
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.chatbox-input:focus {
|
||||
border-color: #6a6a6a;
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.chatbox-input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
|
||||
/* Markdown styling in messages */
|
||||
.message-content h1,
|
||||
.message-content h2,
|
||||
.message-content h3 {
|
||||
margin: 8px 0 4px 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.message-content h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-content p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.message-content ul,
|
||||
.message-content ol {
|
||||
margin: 4px 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.message-content li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.message-content code {
|
||||
background-color: #2a2a2a;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.message-content pre {
|
||||
background-color: #1a1a1a;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.message-content blockquote {
|
||||
border-left: 3px solid #5a5a5a;
|
||||
padding-left: 12px;
|
||||
margin: 8px 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.message-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.message-content th,
|
||||
.message-content td {
|
||||
border: 1px solid #4a4a4a;
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message-content th {
|
||||
background-color: #3a3a3a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Abort link styling */
|
||||
.abort-link {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
color: #7b68ee !important;
|
||||
text-decoration: underline !important;
|
||||
cursor: pointer !important;
|
||||
padding: 0 !important;
|
||||
font: inherit !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.abort-link:hover {
|
||||
color: #9b88ff !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* Tool XML Expander */
|
||||
.tool-xml-expander {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tool-xml-expander-header {
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 4px 8px;
|
||||
/* background-color: #f5f5f5; */
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-xml-expander-arrow {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tool-xml-expander-title {
|
||||
/* font-family: 'Courier New', Monaco, 'Menlo', 'Ubuntu Mono', monospace; */
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.tool-xml-expander-content {
|
||||
margin-top: 4px;
|
||||
padding: 8px;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', Monaco, 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
max-height: 200px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Processing wave animation */
|
||||
.processing-wave {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(135, 206, 250, 0.5) 0%,
|
||||
rgba(135, 206, 250, 0.8) 25%,
|
||||
rgb(171, 218, 250) 50%,
|
||||
rgba(135, 206, 250, 0.8) 75%,
|
||||
rgba(135, 206, 250, 0.5) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: wave 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% {
|
||||
background-position: 200% 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0%;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||
import './ChatBox.css';
|
||||
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
|
||||
import { UserMessage, AssistantMessage } from './chat';
|
||||
import { AgentCore } from '../agent/core/AgentCore';
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* Instrument Selection Panel */
|
||||
.instrument-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--instrument-selection-width);
|
||||
background-color: #2d2d2d;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.instrument-selection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.instrument-selection-header h3 {
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.instrument-selection-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.instrument-selection-close-btn:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.instrument-selection-top {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
overflow: hidden;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)),
|
||||
url('/resources/instrument_bg.png');
|
||||
background-size: cover; /* fill while keeping aspect ratio */
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.instrument-preview {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.instrument-preview img {
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.instrument-name-overlay {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #e0e0e0;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
|
||||
z-index: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.instrument-selection-bottom {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: 0; /* allow children to scroll */
|
||||
}
|
||||
|
||||
.instrument-groups, .instrument-list {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* vertical divider between groups and instruments */
|
||||
.instrument-groups {
|
||||
border-right: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.instrument-groups-list, .instrument-instruments-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.instrument-group-item, .instrument-instrument-item {
|
||||
padding: 8px 12px;
|
||||
/* remove per-item separators */
|
||||
border-bottom: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.instrument-group-item:hover, .instrument-instrument-item:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.instrument-group-item.active, .instrument-instrument-item.active {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import './InstrumentSelection.css';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
|
||||
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* Main content */
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: auto; /* Main scrollable container */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: calc(200px + var(--max-number-of-bars) * var(--track-grid-bar-width)); /* info width + grid width */
|
||||
min-height: fit-content;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.top-left-spacer {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
width: 200px;
|
||||
height: 20px;
|
||||
background-color: #2d2d2d;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
z-index: 1002; /* Higher than other elements to ensure it's always visible */
|
||||
}
|
||||
|
||||
/* Offset spacer when instrument selection panel is visible on the left */
|
||||
.main-content.has-left-instrument .top-left-spacer {
|
||||
left: 300px;
|
||||
}
|
||||
|
||||
.bar-numbers {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background-color: #2d2d2d;
|
||||
z-index: 20;
|
||||
margin-left: 200px; /* Offset for info-container */
|
||||
width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Exact width for 32 bars */
|
||||
cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */
|
||||
}
|
||||
|
||||
.bar-numbers:hover {
|
||||
cursor: url("data:image/svg+xml,%3csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M4 6h8l-4 4-4-4z' fill='%23e0e0e0'/%3e%3c/svg%3e") 8 8, pointer;
|
||||
}
|
||||
|
||||
.bar-number-cell {
|
||||
min-width: var(--track-grid-bar-width);
|
||||
width: var(--track-grid-bar-width);
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bar-number-cell.looped {
|
||||
background-color: #e1ae01;
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
.main-content-body {
|
||||
display: flex;
|
||||
min-height: fit-content;
|
||||
}
|
||||
|
||||
.info-container {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
width: 200px;
|
||||
z-index: 1002;
|
||||
background-color: #2d2d2d;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Shift the bar numbers and info panel when instrument selection is present */
|
||||
/* No need to shift bar numbers or info container; they live inside main content.
|
||||
Only shift the fixed spacer when the left panel is present. */
|
||||
|
||||
.track-top-spacer {
|
||||
height: 20px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background-color: #2d2d2d;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
margin-left: 0; /* No need for margin since we're using flex */
|
||||
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Ensure minimum width */
|
||||
min-height: fit-content;
|
||||
position: relative; /* Required for absolute positioned playhead */
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import './MainContent.css';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
@@ -18,7 +19,7 @@ interface MainContentProps {
|
||||
}
|
||||
|
||||
const MainContent: React.FC<MainContentProps> = ({
|
||||
onTrackClick = () => {} // Default to empty function if not provided
|
||||
onTrackClick = () => { } // Default to empty function if not provided
|
||||
}) => {
|
||||
const {
|
||||
tracks,
|
||||
@@ -33,18 +34,19 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
showPianoRoll,
|
||||
activeRegionId,
|
||||
setShowPianoRoll,
|
||||
setActiveRegionId
|
||||
setActiveRegionId,
|
||||
addTrack
|
||||
} = useProjectStore();
|
||||
|
||||
|
||||
// State to store regions
|
||||
const [regions, setRegions] = useState<RegionUI[]>([]);
|
||||
|
||||
|
||||
// Drag state for track grid highlighting
|
||||
const [draggedTrackIndex, setDraggedTrackIndex] = useState<number | null>(null);
|
||||
const [dragOverTrackIndex, setDragOverTrackIndex] = useState<number | null>(null);
|
||||
|
||||
|
||||
// Piano roll state is now managed by the store - removed local state
|
||||
|
||||
|
||||
// Region selection state
|
||||
const [selectedRegionId, setSelectedRegionId] = useState<string | null>(null);
|
||||
|
||||
@@ -64,16 +66,16 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Register the delete function with the global manager
|
||||
useEffect(() => {
|
||||
regionDeleteManager.registerDeleteCallback(deleteSelectedRegions);
|
||||
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
regionDeleteManager.unregisterDeleteCallback();
|
||||
};
|
||||
}, [deleteSelectedRegions]);
|
||||
|
||||
|
||||
// Refs to track pending updates for verification
|
||||
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
||||
|
||||
|
||||
// Refs for bar numbers and loop range drag functionality
|
||||
const barNumbersRef = useRef<HTMLDivElement | null>(null);
|
||||
const isLoopDraggingRef = useRef(false);
|
||||
@@ -87,26 +89,26 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
if (pendingUpdates.current.size > 0) {
|
||||
// Create a copy of the pending updates
|
||||
const updates = new Map(pendingUpdates.current);
|
||||
|
||||
|
||||
// Clear pending updates
|
||||
pendingUpdates.current.clear();
|
||||
|
||||
|
||||
// Check each update
|
||||
updates.forEach((update, key) => {
|
||||
const { trackId, regionId, startBeat, length } = update;
|
||||
|
||||
|
||||
// Find the track
|
||||
const track = tracks.find(t => t.getId().toString() === trackId);
|
||||
if (track) {
|
||||
// Find the region
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
|
||||
|
||||
if (region && DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`Verification - Region ${regionId} in track ${trackId}:`);
|
||||
console.log(` Expected: startBeat=${startBeat}, length=${length}`);
|
||||
console.log(` Actual: startBeat=${region.getStartFromBeat()}, length=${region.getLength()}, trackId=${region.getTrackId()}, trackIndex=${region.getTrackIndex()}`);
|
||||
|
||||
|
||||
// Check if the update was successful
|
||||
const success = region.getStartFromBeat() === startBeat && region.getLength() === length && region.getTrackId() === trackId;
|
||||
console.log(` Update successful: ${success}`);
|
||||
@@ -120,12 +122,12 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
useEffect(() => {
|
||||
// Create a new array of RegionUI objects based on the current tracks
|
||||
const updatedRegions: RegionUI[] = [];
|
||||
|
||||
|
||||
// Iterate through all tracks
|
||||
tracks.forEach(track => {
|
||||
const trackId = track.getId().toString();
|
||||
const trackIndex = track.getTrackIndex();
|
||||
|
||||
|
||||
// Iterate through all regions in the track
|
||||
track.getRegions().forEach(region => {
|
||||
if (region instanceof KGMidiRegion) {
|
||||
@@ -133,7 +135,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1;
|
||||
const length = region.getLength() / beatsPerBar;
|
||||
|
||||
|
||||
// Create a RegionUI object
|
||||
updatedRegions.push({
|
||||
id: region.getId(),
|
||||
@@ -146,7 +148,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Update the regions state
|
||||
setRegions(updatedRegions);
|
||||
}, [tracks, timeSignature]);
|
||||
@@ -161,7 +163,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const handleTracksReordered = (fromIndex: number, toIndex: number) => {
|
||||
// Reorder tracks in the store - this will also update trackIndex in each KGTrack
|
||||
reorderTracks(fromIndex, toIndex);
|
||||
|
||||
|
||||
// Update regions to match the new track order
|
||||
setRegions(prevRegions => {
|
||||
return prevRegions.map(region => {
|
||||
@@ -171,17 +173,17 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}
|
||||
// If the region belongs to a track that was shifted due to the drag operation
|
||||
else if (
|
||||
(fromIndex < toIndex &&
|
||||
region.trackIndex > fromIndex &&
|
||||
region.trackIndex <= toIndex)
|
||||
(fromIndex < toIndex &&
|
||||
region.trackIndex > fromIndex &&
|
||||
region.trackIndex <= toIndex)
|
||||
) {
|
||||
// Shift up by 1
|
||||
return { ...region, trackIndex: region.trackIndex - 1 };
|
||||
}
|
||||
else if (
|
||||
(fromIndex > toIndex &&
|
||||
region.trackIndex < fromIndex &&
|
||||
region.trackIndex >= toIndex)
|
||||
(fromIndex > toIndex &&
|
||||
region.trackIndex < fromIndex &&
|
||||
region.trackIndex >= toIndex)
|
||||
) {
|
||||
// Shift down by 1
|
||||
return { ...region, trackIndex: region.trackIndex + 1 };
|
||||
@@ -190,7 +192,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
return region;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Update the grid drag state to match
|
||||
setDraggedTrackIndex(null);
|
||||
setDragOverTrackIndex(null);
|
||||
@@ -200,53 +202,53 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const handleRegionCreated = (trackIndex: number, regionUI: RegionUI, midiRegion: KGMidiRegion) => {
|
||||
// Note: The region model is already created by the CreateRegionCommand
|
||||
// We just need to update the UI state and handle selection
|
||||
|
||||
|
||||
// Get the track for store updates
|
||||
const track = tracks[trackIndex];
|
||||
|
||||
|
||||
// Update the track in the store to reflect the command changes
|
||||
updateTrack(track);
|
||||
|
||||
|
||||
// Select the track that contains the new region
|
||||
setSelectedTrack(track.getId().toString());
|
||||
|
||||
|
||||
// Add the new region to the UI state and select it immediately
|
||||
setRegions(prevRegions => {
|
||||
const updatedRegions = [...prevRegions, regionUI];
|
||||
|
||||
|
||||
// Select the region using the updated regions array
|
||||
selectRegion(regionUI.id, updatedRegions);
|
||||
|
||||
|
||||
// Manually trigger selection sync to ensure UI updates immediately
|
||||
const { syncSelectionFromCore } = useProjectStore.getState();
|
||||
syncSelectionFromCore();
|
||||
|
||||
|
||||
// If piano roll is visible, set this region as the active region
|
||||
if (showPianoRoll) {
|
||||
setActiveRegionId(regionUI.id);
|
||||
|
||||
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`Newly created region ${regionUI.id} set as active region in piano roll`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return updatedRegions;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Handle region updates (resize, move, etc.)
|
||||
const handleRegionUpdated = (
|
||||
regionId: string,
|
||||
updates: Partial<RegionUI>,
|
||||
regionId: string,
|
||||
updates: Partial<RegionUI>,
|
||||
expectedModelUpdates?: { startBeat: number, length: number }
|
||||
) => {
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`Updating region ${regionId} with:`, updates);
|
||||
}
|
||||
|
||||
|
||||
// Select the region when it's being updated (resize or move)
|
||||
selectRegion(regionId);
|
||||
|
||||
|
||||
// Find the region to determine which track to select
|
||||
const updatedRegion = regions.find(r => r.id === regionId);
|
||||
if (updatedRegion) {
|
||||
@@ -257,7 +259,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setSelectedTrack(track.getId().toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update the region in the UI state
|
||||
setRegions(prevRegions => {
|
||||
return prevRegions.map(region => {
|
||||
@@ -267,31 +269,31 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
return region;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Find the region that was updated
|
||||
const region = regions.find(r => r.id === regionId);
|
||||
if (!region) return;
|
||||
|
||||
|
||||
// Check if the track ID is being updated (region moved to different track)
|
||||
if (updates.trackId && updates.trackId !== region.trackId) {
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`Region ${regionId} moved from track ${region.trackId} to track ${updates.trackId}`);
|
||||
}
|
||||
|
||||
|
||||
// Get the original track
|
||||
const originalTrack = tracks.find(t => t.getId().toString() === region.trackId);
|
||||
|
||||
|
||||
// Get the target track
|
||||
const targetTrack = tracks.find(t => t.getId().toString() === updates.trackId);
|
||||
|
||||
|
||||
if (originalTrack && targetTrack) {
|
||||
// Select the target track that now contains the region
|
||||
setSelectedTrack(targetTrack.getId().toString());
|
||||
|
||||
|
||||
// Update both tracks in the store
|
||||
updateTrack(originalTrack);
|
||||
updateTrack(targetTrack);
|
||||
|
||||
|
||||
// Add to pending updates for verification
|
||||
if (expectedModelUpdates) {
|
||||
const key = `${updates.trackId}-${regionId}-${Date.now()}`;
|
||||
@@ -310,14 +312,14 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Log the track's regions before updating the store
|
||||
const trackRegions = track.getRegions();
|
||||
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
|
||||
|
||||
|
||||
if (midiRegion) {
|
||||
// If we have expected model updates, use those
|
||||
if (expectedModelUpdates) {
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`MainContent - Expected model updates: startBeat=${expectedModelUpdates.startBeat}, length=${expectedModelUpdates.length}`);
|
||||
}
|
||||
|
||||
|
||||
// Add to pending updates for verification
|
||||
const key = `${track.getId()}-${regionId}-${Date.now()}`;
|
||||
pendingUpdates.current.set(key, {
|
||||
@@ -330,11 +332,11 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Otherwise use the current values (for backward compatibility)
|
||||
const startBeat = midiRegion.getStartFromBeat();
|
||||
const length = midiRegion.getLength();
|
||||
|
||||
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`MainContent - Region before store update: startBeat=${startBeat}, length=${length}`);
|
||||
}
|
||||
|
||||
|
||||
// Add to pending updates for verification
|
||||
const key = `${track.getId()}-${regionId}-${Date.now()}`;
|
||||
pendingUpdates.current.set(key, {
|
||||
@@ -345,16 +347,16 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update the track in the store to persist changes
|
||||
updateTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If piano roll is visible, set this region as the active region
|
||||
if (showPianoRoll) {
|
||||
setActiveRegionId(regionId);
|
||||
|
||||
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`Updated region ${regionId} set as active region in piano roll`);
|
||||
}
|
||||
@@ -365,7 +367,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const selectRegion = (regionId: string, regionsToSearch?: RegionUI[]) => {
|
||||
// Clear any existing selections using store method
|
||||
clearAllSelections();
|
||||
|
||||
|
||||
// Find the region in the UI state (use provided regions or current state)
|
||||
const regionsToUse = regionsToSearch || regions;
|
||||
const region = regionsToUse.find(r => r.id === regionId);
|
||||
@@ -375,7 +377,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Find the track that contains this region
|
||||
const track = tracks.find(t => t.getId().toString() === region.trackId);
|
||||
if (!track) {
|
||||
@@ -384,28 +386,28 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Find the region in the track's model
|
||||
const trackRegions = track.getRegions();
|
||||
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
|
||||
|
||||
|
||||
if (!midiRegion) {
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`MIDI region not found in track model: ${regionId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Add the region to KGCore's selection
|
||||
const core = KGCore.instance();
|
||||
core.addSelectedItem(midiRegion);
|
||||
|
||||
|
||||
// Update the region's internal selection state
|
||||
midiRegion.select();
|
||||
|
||||
|
||||
// Set the selected region (this might be redundant now, but keeping for compatibility)
|
||||
setSelectedRegionId(regionId);
|
||||
|
||||
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`Selected region: ${regionId} (added to KGCore selection)`);
|
||||
}
|
||||
@@ -416,10 +418,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`Region clicked in MainContent (selection only): ${regionId}`);
|
||||
}
|
||||
|
||||
|
||||
// Select the region
|
||||
selectRegion(regionId);
|
||||
|
||||
|
||||
// Also select the containing track
|
||||
const region = regions.find(r => r.id === regionId);
|
||||
if (!region) return;
|
||||
@@ -433,10 +435,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||
console.log(`Open piano roll via pencil for region: ${regionId}`);
|
||||
}
|
||||
|
||||
|
||||
// Reuse selection logic
|
||||
handleRegionClick(regionId);
|
||||
|
||||
|
||||
// Activate and show piano roll
|
||||
setActiveRegionId(regionId);
|
||||
setShowPianoRoll(true);
|
||||
@@ -458,8 +460,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Skip if user is typing in an input field (including ChatBox)
|
||||
const target = event.target as HTMLElement;
|
||||
if (target && (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.contentEditable === 'true' ||
|
||||
target.hasAttribute('data-chatbox-input') ||
|
||||
target.closest('.chatbox-input')
|
||||
@@ -472,7 +474,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Only handle if we're not in the piano roll (piano roll has its own delete handler)
|
||||
const isInPianoRoll = document.querySelector('.piano-roll')?.contains(event.target as Node);
|
||||
const isPianoRollOpen = showPianoRoll;
|
||||
|
||||
|
||||
if (!isInPianoRoll && !isPianoRollOpen) {
|
||||
const deleted = deleteSelectedRegions();
|
||||
if (deleted) {
|
||||
@@ -482,10 +484,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
@@ -495,25 +497,25 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
// Utility function to calculate playhead position from mouse coordinates (bar-level snapping)
|
||||
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
||||
if (!barNumbersRef.current) return null;
|
||||
|
||||
|
||||
const rect = barNumbersRef.current.getBoundingClientRect();
|
||||
const relativeX = clientX - rect.left;
|
||||
|
||||
|
||||
// Calculate the width of each bar
|
||||
const barWidth = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||
) || 40;
|
||||
|
||||
|
||||
// Find the closest bar start (using Math.round for nearest bar)
|
||||
const barIndex = Math.round(relativeX / barWidth);
|
||||
|
||||
|
||||
// Ensure we don't go below 0
|
||||
const clampedBarIndex = Math.max(0, barIndex);
|
||||
|
||||
|
||||
// Calculate destination beat position (start of the bar)
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const destinationBeatPosition = clampedBarIndex * beatsPerBar;
|
||||
|
||||
|
||||
return destinationBeatPosition;
|
||||
}, [timeSignature]);
|
||||
|
||||
@@ -688,7 +690,9 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
|
||||
<div className="main-content-wrapper">
|
||||
{/* Top-left spacer */}
|
||||
<div className="top-left-spacer"></div>
|
||||
<div className="top-left-spacer">
|
||||
<button className="add-track-btn" onClick={() => addTrack()}>+ Add track</button>
|
||||
</div>
|
||||
|
||||
{/* Bar numbers at the top */}
|
||||
<div
|
||||
@@ -705,7 +709,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="main-content-body">
|
||||
{/* Fixed left panel with track info */}
|
||||
<TrackInfoPanel
|
||||
@@ -714,7 +718,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
onTrackNameEdit={handleTrackNameEdit}
|
||||
onTracksReordered={handleTracksReordered}
|
||||
/>
|
||||
|
||||
|
||||
{/* Scrollable grid area */}
|
||||
<TrackGridPanel
|
||||
tracks={tracks}
|
||||
@@ -731,10 +735,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Piano Roll - render using portal */}
|
||||
{showPianoRoll && createPortal(
|
||||
<PianoRoll
|
||||
<PianoRoll
|
||||
onClose={handlePianoRollClose}
|
||||
regionId={activeRegionId}
|
||||
/>,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #2d2d2d;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
border-top: 1px solid #3a3a3a;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.status-right span {
|
||||
margin-left: 15px;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import './StatusBar.css';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
|
||||
const StatusBar: React.FC = () => {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #2d2d2d;
|
||||
height: 50px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.toolbar-left, .toolbar-center, .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 30px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
margin-right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
margin: 0 5px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.toolbar button:disabled {
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: #555;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.transport-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 10px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.transport-item {
|
||||
margin: 0 5px;
|
||||
padding: 2px 2px;
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Fixed-width font for time display to prevent layout shifts */
|
||||
.current-time {
|
||||
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
/* Clickable BPM styling */
|
||||
.current-bpm {
|
||||
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.current-bpm:hover {
|
||||
background-color: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Clickable time signature styling */
|
||||
.current-time-signature {
|
||||
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.current-time-signature:hover {
|
||||
background-color: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.current-key-signature {
|
||||
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.current-key-signature:hover {
|
||||
background-color: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.export-dropdown .quant-dropdown {
|
||||
width: 250px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.key-signature-dropdown .quant-dropdown {
|
||||
width: 100px;
|
||||
left: 0;
|
||||
}
|
||||
+90
-61
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import './Toolbar.css';
|
||||
import { saveProject } from '../util/saveUtil';
|
||||
import { KGStorage } from '../core/io/KGStorage';
|
||||
import { DB_CONSTANTS } from '../constants/coreConstants';
|
||||
import { KGProjectStorage } from '../core/io/KGProjectStorage';
|
||||
import { isValidProjectName } from '../util/projectNameUtil';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { DEBUG_MODE } from '../constants/uiConstants';
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
FaCog
|
||||
} from 'react-icons/fa';
|
||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||
import { plainToInstance, instanceToPlain } from 'class-transformer';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6';
|
||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||
@@ -58,11 +59,17 @@ const Toolbar: React.FC = () => {
|
||||
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
|
||||
|
||||
// Export options
|
||||
const exportOptions = ["Export to KGStudio JSON file", "Export to MIDI file"];
|
||||
const exportOptions = ["Export to KGStudio file", "Export to MIDI file"];
|
||||
|
||||
const handleProjectNameClick = () => {
|
||||
const newName = prompt("Enter project name:", projectName);
|
||||
if (newName) setProjectName(newName);
|
||||
if (newName) {
|
||||
if (!isValidProjectName(newName)) {
|
||||
window.alert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.");
|
||||
return;
|
||||
}
|
||||
setProjectName(newName);
|
||||
}
|
||||
};
|
||||
|
||||
// Common project loading logic extracted for reuse
|
||||
@@ -144,14 +151,8 @@ const Toolbar: React.FC = () => {
|
||||
|
||||
try {
|
||||
// Try to load the project from storage
|
||||
const storage = KGStorage.getInstance();
|
||||
const loadedProject = await storage.load(
|
||||
DB_CONSTANTS.DB_NAME,
|
||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
||||
projectNameToLoad.trim(),
|
||||
KGProject,
|
||||
DB_CONSTANTS.DB_VERSION
|
||||
);
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const loadedProject = await storage.load(projectNameToLoad.trim());
|
||||
|
||||
if (!loadedProject) {
|
||||
window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`);
|
||||
@@ -181,8 +182,8 @@ const Toolbar: React.FC = () => {
|
||||
console.log("user selected export option:", exportType);
|
||||
}
|
||||
|
||||
if (exportType === "Export to KGStudio JSON file") {
|
||||
handleExportKGStudioJSON();
|
||||
if (exportType === "Export to KGStudio file") {
|
||||
handleExportKGStudio();
|
||||
} else if (exportType === "Export to MIDI file") {
|
||||
handleExportMIDI();
|
||||
}
|
||||
@@ -190,46 +191,39 @@ const Toolbar: React.FC = () => {
|
||||
setShowExportDropdown(false);
|
||||
};
|
||||
|
||||
const handleExportKGStudioJSON = () => {
|
||||
const handleExportKGStudio = async () => {
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("exporting to KGStudio JSON file");
|
||||
console.log("exporting to KGStudio file");
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Get the current project from KGCore
|
||||
const currentProject = KGCore.instance().getCurrentProject();
|
||||
|
||||
// Serialize the project to JSON (same format as saved to IndexedDB)
|
||||
// Use instanceToPlain to include type information for class-transformer
|
||||
const projectData = JSON.stringify(instanceToPlain(currentProject), null, 2);
|
||||
|
||||
// Create a downloadable blob
|
||||
const blob = new Blob([projectData], { type: 'application/json' });
|
||||
|
||||
// Create a temporary download link
|
||||
// First save the current project to OPFS so the export reflects the latest state
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
|
||||
|
||||
// Bundle the project folder into a zip
|
||||
const blob = await storage.exportAsZip(projectName);
|
||||
|
||||
// Trigger download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${projectName}.json`;
|
||||
|
||||
// Trigger download
|
||||
link.download = `${projectName}.kgstudio`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setStatus(`Project "${projectName}" exported as JSON file`);
|
||||
|
||||
|
||||
setStatus(`Project "${projectName}" exported as KGStudio file`);
|
||||
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("KGStudio JSON export completed successfully");
|
||||
console.log("KGStudio export completed successfully");
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error exporting KGStudio JSON:", error);
|
||||
console.error("Error exporting KGStudio file:", error);
|
||||
setStatus(`Error exporting project: ${error}`);
|
||||
window.alert(`Failed to export project as JSON: ${error}`);
|
||||
window.alert(`Failed to export project: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -291,8 +285,11 @@ const Toolbar: React.FC = () => {
|
||||
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
try {
|
||||
if (fileExtension === '.json') {
|
||||
// Handle KGStudio JSON import
|
||||
if (fileExtension === '.kgstudio') {
|
||||
// Handle KGStudio bundle import
|
||||
await handleKGStudioFileImport(file);
|
||||
} else if (fileExtension === '.json') {
|
||||
// Handle legacy KGStudio JSON import
|
||||
await handleKGStudioJSONImport(file);
|
||||
} else if (fileExtension === '.mid' || fileExtension === '.midi') {
|
||||
// Handle MIDI import
|
||||
@@ -308,32 +305,64 @@ const Toolbar: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleKGStudioFileImport = async (file: File) => {
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
|
||||
try {
|
||||
const projectName = await storage.importFromZip(file);
|
||||
|
||||
// Load the project from OPFS (this runs the upgrader)
|
||||
const loaded = await storage.load(projectName);
|
||||
if (!loaded) {
|
||||
throw new Error('Failed to load imported project');
|
||||
}
|
||||
|
||||
await loadProjectFromData(loaded, `KGStudio file "${file.name}"`);
|
||||
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("KGStudio file imported successfully:", projectName);
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(`The .kgstudio file is corrupted or invalid: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKGStudioJSONImport = async (file: File) => {
|
||||
try {
|
||||
// Read the file content
|
||||
const fileContent = await file.text();
|
||||
|
||||
const projectData = JSON.parse(fileContent);
|
||||
|
||||
// Deserialize the project data using class-transformer (same as KGStorage)
|
||||
|
||||
// Deserialize and handle potential array return
|
||||
const deserializedResult = plainToInstance(KGProject, projectData);
|
||||
|
||||
// Handle case where plainToInstance might return an array
|
||||
const deserializedProject = Array.isArray(deserializedResult)
|
||||
? deserializedResult[0] || null
|
||||
const project = Array.isArray(deserializedResult)
|
||||
? deserializedResult[0] || null
|
||||
: deserializedResult;
|
||||
|
||||
if (!deserializedProject) {
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Failed to deserialize project data");
|
||||
}
|
||||
|
||||
// Load the project using common loading logic
|
||||
await loadProjectFromData(deserializedProject, `File "${file.name}"`);
|
||||
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("KGStudio JSON project imported successfully:", deserializedProject);
|
||||
|
||||
// Use the project's name for the OPFS folder, auto-rename if it already exists
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const importedName = await storage.resolveUniqueName(project.getName() || 'Imported Project');
|
||||
|
||||
// Save to OPFS as a proper folder-based project
|
||||
project.setName(importedName);
|
||||
await storage.save(importedName, project, false);
|
||||
|
||||
// Load back from OPFS so the upgrader runs
|
||||
const loaded = await storage.load(importedName);
|
||||
if (!loaded) {
|
||||
throw new Error('Failed to load imported project from storage');
|
||||
}
|
||||
|
||||
|
||||
await loadProjectFromData(loaded, `JSON file "${file.name}"`);
|
||||
|
||||
if (DEBUG_MODE.TOOLBAR) {
|
||||
console.log("KGStudio JSON project imported and saved to OPFS:", importedName);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid KGStudio JSON file: ${error}`);
|
||||
}
|
||||
@@ -803,7 +832,7 @@ const Toolbar: React.FC = () => {
|
||||
isVisible={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onFileImport={handleFileImport}
|
||||
acceptedTypes={['.json', '.mid', '.midi']}
|
||||
acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
|
||||
title="Import Project"
|
||||
description="Drag and drop your project file here"
|
||||
/>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
|
||||
const TrackControl: React.FC = () => {
|
||||
const { addTrack } = useProjectStore();
|
||||
|
||||
const handleAddTrack = () => {
|
||||
addTrack();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="track-control">
|
||||
<button onClick={handleAddTrack}>+ Add track</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackControl;
|
||||
@@ -0,0 +1,161 @@
|
||||
/* File Import Modal */
|
||||
.file-import-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.file-import-modal {
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
animation: fileImportFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fileImportFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.file-import-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.file-import-title {
|
||||
color: #e0e0e0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-import-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b0b0b0;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-import-close-btn:hover {
|
||||
background-color: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.file-import-drop-zone {
|
||||
padding: 40px 20px;
|
||||
margin: 20px;
|
||||
border: 2px dashed #555;
|
||||
border-radius: 8px;
|
||||
background-color: #1e1e1e;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-import-drop-zone:hover {
|
||||
border-color: #7b68ee;
|
||||
background-color: rgba(123, 104, 238, 0.05);
|
||||
}
|
||||
|
||||
.file-import-drop-zone.drag-over {
|
||||
border-color: #7b68ee;
|
||||
background-color: rgba(123, 104, 238, 0.1);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.file-import-drop-content {
|
||||
text-align: center;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.file-import-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.file-import-description {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 8px 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.file-import-formats {
|
||||
font-size: 14px;
|
||||
color: #b0b0b0;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.file-import-divider {
|
||||
position: relative;
|
||||
margin: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-import-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.file-import-divider span {
|
||||
background-color: #1e1e1e;
|
||||
padding: 0 12px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.file-import-browse-btn {
|
||||
display: inline-block;
|
||||
background-color: #5a9fd4;
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.file-import-browse-btn:hover {
|
||||
background-color: #4a8fc4;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(90, 159, 212, 0.3);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import './FileImportModal.css';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
|
||||
interface FileImportModalProps {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Global Loading Overlay */
|
||||
.global-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: 20000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: all; /* block interactions */
|
||||
}
|
||||
|
||||
.global-loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.global-loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: globalSpinner 1s linear infinite;
|
||||
}
|
||||
|
||||
.global-loading-text {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes globalSpinner {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import './LoadingOverlay.css';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/* For the piano roll (to be implemented later) */
|
||||
.piano-roll {
|
||||
background-color: #7b68ee;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Piano Roll Panel */
|
||||
.piano-roll-panel {
|
||||
position: fixed;
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #3a3a3a;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Piano notes */
|
||||
.piano-note {
|
||||
position: absolute;
|
||||
background-color: #ff5555; /* Red color */
|
||||
border: 1px solid #999;
|
||||
border-radius: 2px;
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.piano-note:hover {
|
||||
filter: brightness(1.1);
|
||||
box-shadow: 0 0 5px rgba(255, 85, 85, 0.5);
|
||||
}
|
||||
|
||||
.piano-note.dragging {
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.piano-note.resizing {
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 10px rgba(255, 85, 85, 0.7);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.piano-note.selected {
|
||||
border: 2px solid white;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.piano-roll-header {
|
||||
height: 30px;
|
||||
background-color: #1e1e1e;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Piano roll toolbar */
|
||||
.piano-roll-toolbar {
|
||||
height: 30px;
|
||||
background-color: #252525;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 100; /* Ensure toolbar and its dropdowns appear above piano roll content */
|
||||
}
|
||||
|
||||
/* Override pointer-events for piano roll toolbar sections */
|
||||
.piano-roll-toolbar .toolbar-left,
|
||||
.piano-roll-toolbar .toolbar-right {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.piano-roll-toolbar .quant-button {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.piano-roll-toolbar .toolbar-left .quant-button {
|
||||
margin-left: 0px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.piano-roll-toolbar .toolbar-left .quant-dropdown {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.piano-roll-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.piano-roll-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.piano-grid-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--max-number-of-bars), var(--region-grid-bar-width));
|
||||
width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width) - 1px);
|
||||
min-width: 100%;
|
||||
height: 20px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background-color: #1e1e1e;
|
||||
padding-left: calc(var(--region-piano-key-width) - 1px);
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */
|
||||
}
|
||||
|
||||
.piano-grid-header:hover {
|
||||
cursor: url("data:image/svg+xml,%3csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M4 6h8l-4 4-4-4z' fill='%23e0e0e0'/%3e%3c/svg%3e") 8 8, pointer;
|
||||
}
|
||||
|
||||
.piano-bar-number {
|
||||
border-left: 1px solid #3a3a3a;
|
||||
padding-left: 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.piano-roll-body {
|
||||
display: flex;
|
||||
min-height: calc(8 * 12 * var(--region-piano-key-height)); /* 8 octaves * 12 notes * piano key height */
|
||||
width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width)); /* 32 bars * 160px width + piano keys width */
|
||||
}
|
||||
|
||||
.piano-keys-container {
|
||||
width: var(--region-piano-key-width);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
background-color: #252525;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.piano-grid-container {
|
||||
flex: 1;
|
||||
min-width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width));
|
||||
min-height: calc(8 * 12 * var(--region-piano-key-height));
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.piano-octave {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.piano-key {
|
||||
width: var(--region-piano-key-width);
|
||||
height: var(--region-piano-key-height);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.piano-key.natural {
|
||||
background-color: #e0e0e0;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.piano-key.sharp {
|
||||
background-color: #222;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.key-label {
|
||||
font-size: 10px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.piano-grid {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%;
|
||||
/* background-image is now set dynamically via React inline styles in PianoGrid component */
|
||||
}
|
||||
|
||||
.piano-grid.pencil-cursor {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* Piano Grid Cursor Highlights */
|
||||
.piano-grid-pitch-highlight {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(123, 104, 238, 0.15);
|
||||
border-top: 1px solid rgba(123, 104, 238, 0.3);
|
||||
border-bottom: 1px solid rgba(123, 104, 238, 0.3);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.piano-grid-beat-highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(123, 104, 238, 0.1);
|
||||
border-left: 1px solid rgba(123, 104, 238, 0.25);
|
||||
border-right: 1px solid rgba(123, 104, 238, 0.25);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.piano-grid-chord-highlight {
|
||||
position: absolute;
|
||||
background-color: rgba(255, 77, 77, 0.25);
|
||||
border: 1px solid rgba(255, 77, 77, 0.8);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: nwse-resize;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import './PianoRoll.css';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { FaGripLines } from 'react-icons/fa';
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
/* Settings Panel Styles */
|
||||
.settings-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #1e1e1e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Settings Sidebar */
|
||||
.settings-sidebar {
|
||||
width: 250px;
|
||||
background-color: #2d2d2d;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.settings-sidebar-header h2 {
|
||||
color: #e0e0e0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.settings-close-btn:hover {
|
||||
background-color: #3a3a3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.settings-nav-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b0b0b0;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-nav-item:hover {
|
||||
background-color: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.settings-nav-item.active {
|
||||
background-color: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Settings Content */
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: #1e1e1e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
padding: 30px;
|
||||
width: 800px;
|
||||
}
|
||||
|
||||
.settings-section-header {
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.settings-section-header h3 {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
background-color: #2d2d2d;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.settings-group h4 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.settings-group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.settings-group-header h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
color: #b0b0b0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.settings-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
display: block;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.settings-input, .settings-select, .settings-textarea {
|
||||
width: 100%;
|
||||
/* max-width: 300px; */
|
||||
padding: 8px 12px;
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.settings-input:focus, .settings-select:focus, .settings-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #5a9fd4;
|
||||
box-shadow: 0 0 0 2px rgba(90, 159, 212, 0.2);
|
||||
}
|
||||
|
||||
.settings-textarea {
|
||||
max-width: 600px;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.settings-input[type="number"] {
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
/* Custom Checkbox */
|
||||
.settings-checkbox-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-checkbox-container input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-checkmark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 3px;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-checkbox-container input[type="checkbox"]:checked + .settings-checkmark {
|
||||
background-color: #5a9fd4;
|
||||
border-color: #5a9fd4;
|
||||
}
|
||||
|
||||
.settings-checkbox-container input[type="checkbox"]:checked + .settings-checkmark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Settings Buttons */
|
||||
.settings-btn {
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #555;
|
||||
color: #e0e0e0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
background-color: #4a4a4a;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.settings-btn-primary {
|
||||
background-color: #5a9fd4;
|
||||
border-color: #5a9fd4;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-btn-primary:hover {
|
||||
background-color: #4a8fc4;
|
||||
border-color: #4a8fc4;
|
||||
}
|
||||
|
||||
.settings-btn-danger {
|
||||
background-color: #d45a5a;
|
||||
border-color: #d45a5a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-btn-danger:hover {
|
||||
background-color: #c44a4a;
|
||||
border-color: #c44a4a;
|
||||
}
|
||||
|
||||
.settings-btn-danger:disabled {
|
||||
background-color: #555;
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-btn-small {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Settings Help Links */
|
||||
.settings-help-links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
button.settings-help {
|
||||
color: #5a9fd4;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button.settings-help:hover {
|
||||
color: #7bbfef;
|
||||
}
|
||||
|
||||
/* Settings Validation Errors */
|
||||
.settings-validation-errors {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background-color: rgba(211, 90, 90, 0.1);
|
||||
border: 1px solid #d35a5a;
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-validation-error {
|
||||
color: #ff6b6b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.settings-validation-error:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Templates List */
|
||||
.templates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.template-item:hover {
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.template-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
color: #b0b0b0;
|
||||
font-size: 14px;
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
|
||||
.template-tracks {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.template-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import './Settings.css';
|
||||
import SettingsSidebar from './SettingsSidebar';
|
||||
import GeneralSettings from './sections/GeneralSettings';
|
||||
import BehaviorSettings from './sections/BehaviorSettings';
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* Track Region Styles */
|
||||
.track-region {
|
||||
position: absolute;
|
||||
background-color: #4a6b8a;
|
||||
border: 2px solid #5a7b9a;
|
||||
border-radius: 3px;
|
||||
height: calc(100%);
|
||||
margin: 0px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.1s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.track-region:hover {
|
||||
box-shadow: 0 0 0 1px #7a9bba;
|
||||
}
|
||||
|
||||
.track-region.selected {
|
||||
/* box-shadow: 0 0 0 2px #ffffff, 0 0 8px rgba(255, 255, 255, 0.3); */ /* let's only apply a border */
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.track-region.dragging {
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
animation: pulse 1.5s infinite;
|
||||
cursor: grabbing;
|
||||
transition: none; /* Remove transition during drag for immediate response */
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(122, 155, 186, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 5px rgba(122, 155, 186, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(122, 155, 186, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.region-header {
|
||||
background-color: #5a7b9a;
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: move;
|
||||
height: 18px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.track-region:hover .region-header {
|
||||
background-color: #6a8baa;
|
||||
}
|
||||
|
||||
.region-content {
|
||||
height: calc(100% - 18px);
|
||||
background-color: #87CEFA; /* Light blue */
|
||||
width: 100%;
|
||||
position: relative; /* Allow overlayed controls */
|
||||
}
|
||||
|
||||
/* Region pencil trigger inside content */
|
||||
.region-pencil-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
padding: 2px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.region-pencil-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Instrument dropdown specific styles */
|
||||
.instrument-dropdown .quant-dropdown {
|
||||
min-width: 80px;
|
||||
width: auto;
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Settings dropdown specific styles */
|
||||
.settings-dropdown .quant-dropdown {
|
||||
min-width: 100px;
|
||||
width: auto;
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import './Region.css';
|
||||
import { FaPencilAlt } from 'react-icons/fa';
|
||||
import type { ResizeAction } from '../interfaces';
|
||||
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/* Track grid */
|
||||
.track-grid {
|
||||
display: block;
|
||||
height: 120px;
|
||||
min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* 32 bars * --track-grid-bar-width */
|
||||
background-size: var(--track-grid-bar-width) 120px;
|
||||
background-image:
|
||||
/* Vertical lines for bars */
|
||||
linear-gradient(to right,
|
||||
transparent calc(var(--track-grid-bar-width) - 1px), #3a3a3a calc(var(--track-grid-bar-width) - 1px), #3a3a3a var(--track-grid-bar-width)
|
||||
);
|
||||
position: relative;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.track-grid.pencil-cursor {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.add-track-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.add-track-btn:focus,
|
||||
.add-track-btn:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-track-btn:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
height: 120px;
|
||||
position: relative;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
padding: 15px;
|
||||
background-color: #2d2d2d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track-info:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.track-info.selected {
|
||||
background-color: #363636;
|
||||
}
|
||||
|
||||
/* Drag and drop styles */
|
||||
.track-info.drag-over {
|
||||
border-top: 2px solid #7b68ee;
|
||||
}
|
||||
|
||||
.track-grid.drag-over {
|
||||
border-top: 2px solid #7b68ee;
|
||||
}
|
||||
|
||||
/* Dragging state */
|
||||
.track-info.dragging {
|
||||
opacity: 0.5;
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.track-grid.dragging {
|
||||
opacity: 0.5;
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
/* Remove drag indicator since hover cursor is sufficient */
|
||||
|
||||
.track-name {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track-name:hover {
|
||||
color: #7b68ee;
|
||||
}
|
||||
|
||||
.track-name:hover::before {
|
||||
content: "\270E";
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
font-size: 16px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.track-name-and-volume {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.instrument-image {
|
||||
flex-shrink: 0;
|
||||
margin-top: -10px;
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
.instrument-image img {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.track-name-and-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
margin-bottom: 15px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.volume-slider input[type="range"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.volume-slider .reset-volume {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
/* border: 1px solid #444; */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.volume-slider .reset-volume:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.pan-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pan-controls button {
|
||||
background: #3a3a3a;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
margin-right: 5px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pan-controls button.solo.active {
|
||||
background: #c8a400; /* yellow */
|
||||
color: #101010;
|
||||
}
|
||||
|
||||
.pan-controls button.mute.active {
|
||||
background: #d32f2f; /* red */
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import './Track.css';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import TrackInfoItem from './TrackInfoItem';
|
||||
|
||||
@@ -93,6 +93,18 @@ export const SAMPLER_CONSTANTS = {
|
||||
},
|
||||
};
|
||||
|
||||
export const OPFS_CONSTANTS = {
|
||||
ROOT_DIR: 'projects',
|
||||
PROJECT_FILE: 'project.json',
|
||||
METADATA_FILE: 'meta.json',
|
||||
MEDIA_DIR: 'media',
|
||||
};
|
||||
|
||||
export const CONFIG_UPGRADER_CONSTANTS = {
|
||||
VERSION_KEY: '__config_version',
|
||||
CURRENT_VERSION: 1,
|
||||
};
|
||||
|
||||
export const URL_CONSTANTS = {
|
||||
DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
+35
-3
@@ -2,6 +2,8 @@ import type { Selectable } from '../components/interfaces';
|
||||
import { KGProject } from './KGProject';
|
||||
import { KGAudioInterface } from './audio-interface/KGAudioInterface';
|
||||
import { ConfigManager } from './config/ConfigManager';
|
||||
import { KGProjectStorage } from './io/KGProjectStorage';
|
||||
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||
import { KGMidiNote } from './midi/KGMidiNote';
|
||||
import { KGRegion } from './region/KGRegion';
|
||||
@@ -30,6 +32,8 @@ export class KGCore {
|
||||
private copiedItems: Selectable[] = [];
|
||||
|
||||
private isPlaying: boolean = false;
|
||||
private isMigrating: boolean = false;
|
||||
private migrationStateChangeCallback: ((isMigrating: boolean) => void) | null = null;
|
||||
|
||||
// Timer management for playback
|
||||
private playbackIntervalId: number | null = null;
|
||||
@@ -71,11 +75,23 @@ export class KGCore {
|
||||
// Initialize configuration manager
|
||||
const configManager = ConfigManager.instance();
|
||||
await configManager.initialize();
|
||||
|
||||
|
||||
// Initialize OPFS project storage
|
||||
const projectStorage = KGProjectStorage.getInstance();
|
||||
await projectStorage.initialize();
|
||||
|
||||
// Run app-level migrations (e.g., IndexedDB -> OPFS project migration)
|
||||
this.setMigrating(true);
|
||||
try {
|
||||
await KGConfigUpgrader.upgradeToLatest();
|
||||
} finally {
|
||||
this.setMigrating(false);
|
||||
}
|
||||
|
||||
// Initialize audio interface
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
await audioInterface.initialize();
|
||||
|
||||
|
||||
console.log("KGCore components initialized successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize KGCore:", error);
|
||||
@@ -194,7 +210,23 @@ export class KGCore {
|
||||
this.selectionChangeCallbacks.forEach(callback => callback());
|
||||
}
|
||||
|
||||
// play
|
||||
// Migration state
|
||||
public getIsMigrating(): boolean {
|
||||
return this.isMigrating;
|
||||
}
|
||||
|
||||
private setMigrating(value: boolean): void {
|
||||
this.isMigrating = value;
|
||||
if (this.migrationStateChangeCallback) {
|
||||
this.migrationStateChangeCallback(value);
|
||||
}
|
||||
}
|
||||
|
||||
public setMigrationStateChangeCallback(callback: (isMigrating: boolean) => void): void {
|
||||
this.migrationStateChangeCallback = callback;
|
||||
}
|
||||
|
||||
// play
|
||||
public getIsPlaying(): boolean {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
+215
-1
@@ -28,7 +28,8 @@ export class KGDebugger {
|
||||
'createTestRegion()',
|
||||
'testExtractXMLFromString(input)',
|
||||
'testToolCall(jsonInput)',
|
||||
'inputChatBox(content, interval?)'
|
||||
'inputChatBox(content, interval?)',
|
||||
'opfs(command)',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -344,6 +345,7 @@ export class KGDebugger {
|
||||
console.log(" testExtractXMLFromString(input) - Test XML extraction from string");
|
||||
console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results");
|
||||
console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter");
|
||||
console.log(" opfs(command) - OPFS file browser (pwd, ls, cd, cat, dl)");
|
||||
console.log(" help() - Show this help");
|
||||
console.log("");
|
||||
console.log("💡 Usage tips:");
|
||||
@@ -446,4 +448,216 @@ export class KGDebugger {
|
||||
console.error('❌ Error in inputChatBox:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- OPFS Shell ---
|
||||
|
||||
/** Current working directory path segments (relative to OPFS root) */
|
||||
private opfsCwd: string[] = [];
|
||||
|
||||
/**
|
||||
* Simplified bash-like shell for browsing the OPFS filesystem.
|
||||
*
|
||||
* Supported commands:
|
||||
* pwd — print current directory
|
||||
* ls — list files/folders (like ls -lla)
|
||||
* cd <path> — change directory (supports .., /, relative, and quoted paths)
|
||||
* cat <file> — print file contents
|
||||
* dl <file> — download a file to your local machine
|
||||
*
|
||||
* Usage in console:
|
||||
* await KGDebugger.opfs('pwd')
|
||||
* await KGDebugger.opfs('ls')
|
||||
* await KGDebugger.opfs('cd projects')
|
||||
* await KGDebugger.opfs('cat project.json')
|
||||
*/
|
||||
public async opfs(command: string): Promise<void> {
|
||||
const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
||||
const cmd = parts[0]
|
||||
// Strip surrounding quotes from arguments
|
||||
const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ')
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'pwd':
|
||||
console.log('/' + this.opfsCwd.join('/'))
|
||||
break
|
||||
|
||||
case 'ls':
|
||||
await this.opfsLs()
|
||||
break
|
||||
|
||||
case 'cd':
|
||||
await this.opfsCd(arg)
|
||||
break
|
||||
|
||||
case 'cat':
|
||||
await this.opfsCat(arg)
|
||||
break
|
||||
|
||||
case 'dl':
|
||||
await this.opfsDl(arg)
|
||||
break
|
||||
|
||||
default:
|
||||
console.log(`opfs: command not found: ${cmd}`)
|
||||
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`opfs: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async opfsResolveCwd(): Promise<FileSystemDirectoryHandle> {
|
||||
let dir = await navigator.storage.getDirectory()
|
||||
for (const segment of this.opfsCwd) {
|
||||
dir = await dir.getDirectoryHandle(segment)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
private async opfsLs(): Promise<void> {
|
||||
const dir = await this.opfsResolveCwd()
|
||||
const entries: Array<{ kind: string; name: string; size: number; modified: string }> = []
|
||||
|
||||
for await (const entry of dir.values()) {
|
||||
if (entry.kind === 'file') {
|
||||
const fileHandle = entry as FileSystemFileHandle
|
||||
const file = await fileHandle.getFile()
|
||||
entries.push({
|
||||
kind: 'file',
|
||||
name: entry.name,
|
||||
size: file.size,
|
||||
modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19),
|
||||
})
|
||||
} else {
|
||||
entries.push({
|
||||
kind: 'dir',
|
||||
name: entry.name + '/',
|
||||
size: 0,
|
||||
modified: '-',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, alphabetically within each group
|
||||
entries.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log('(empty directory)')
|
||||
return
|
||||
}
|
||||
|
||||
// Print header
|
||||
const cwdPath = '/' + this.opfsCwd.join('/')
|
||||
console.log(`total ${entries.length} (${cwdPath})`)
|
||||
|
||||
// Format like ls -lla
|
||||
const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4)
|
||||
for (const e of entries) {
|
||||
const typeChar = e.kind === 'dir' ? 'd' : '-'
|
||||
const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'
|
||||
const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen)
|
||||
console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async opfsCd(path: string): Promise<void> {
|
||||
if (!path || path === '') {
|
||||
// cd with no args goes to root
|
||||
this.opfsCwd = []
|
||||
return
|
||||
}
|
||||
|
||||
let segments: string[]
|
||||
|
||||
if (path === '/') {
|
||||
this.opfsCwd = []
|
||||
return
|
||||
} else if (path.startsWith('/')) {
|
||||
// Absolute path
|
||||
segments = path.split('/').filter(Boolean)
|
||||
} else {
|
||||
// Relative path
|
||||
segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)]
|
||||
}
|
||||
|
||||
// Resolve . and ..
|
||||
const resolved: string[] = []
|
||||
for (const seg of segments) {
|
||||
if (seg === '.') continue
|
||||
if (seg === '..') {
|
||||
resolved.pop()
|
||||
} else {
|
||||
resolved.push(seg)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the path exists
|
||||
let dir = await navigator.storage.getDirectory()
|
||||
for (const seg of resolved) {
|
||||
try {
|
||||
dir = await dir.getDirectoryHandle(seg)
|
||||
} catch {
|
||||
console.error(`opfs: cd: no such directory: ${path}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.opfsCwd = resolved
|
||||
}
|
||||
|
||||
private async opfsCat(fileName: string): Promise<void> {
|
||||
if (!fileName) {
|
||||
console.error('opfs: cat: missing file name')
|
||||
return
|
||||
}
|
||||
|
||||
const dir = await this.opfsResolveCwd()
|
||||
try {
|
||||
const fileHandle = await dir.getFileHandle(fileName)
|
||||
const file = await fileHandle.getFile()
|
||||
const text = await file.text()
|
||||
|
||||
// Pretty-print JSON files
|
||||
if (fileName.endsWith('.json')) {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
console.log(JSON.stringify(parsed, null, 2))
|
||||
} catch {
|
||||
console.log(text)
|
||||
}
|
||||
} else {
|
||||
console.log(text)
|
||||
}
|
||||
} catch {
|
||||
console.error(`opfs: cat: ${fileName}: No such file`)
|
||||
}
|
||||
}
|
||||
|
||||
private async opfsDl(fileName: string): Promise<void> {
|
||||
if (!fileName) {
|
||||
console.error('opfs: dl: missing file name')
|
||||
return
|
||||
}
|
||||
|
||||
const dir = await this.opfsResolveCwd()
|
||||
try {
|
||||
const fileHandle = await dir.getFileHandle(fileName)
|
||||
const file = await fileHandle.getFile()
|
||||
const url = URL.createObjectURL(file)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
console.log(`downloaded: ${fileName} (${file.size} bytes)`)
|
||||
} catch {
|
||||
console.error(`opfs: dl: ${fileName}: No such file`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { KGConfigStorage } from '../io/KGConfigStorage';
|
||||
import { CONFIG_UPGRADER_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
||||
|
||||
/**
|
||||
* KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes).
|
||||
* Mirrors the KGProjectUpgrader pattern but operates on global app state, not individual projects.
|
||||
*
|
||||
* Version is tracked via a `__config_version` key in the IndexedDB config store.
|
||||
*/
|
||||
export class KGConfigUpgrader {
|
||||
/**
|
||||
* Run all pending config upgrades sequentially.
|
||||
* Returns the number of upgrade steps that were executed.
|
||||
*/
|
||||
public static async upgradeToLatest(): Promise<number> {
|
||||
const storage = KGConfigStorage.getInstance();
|
||||
const currentVersion = await KGConfigUpgrader.getConfigVersion(storage);
|
||||
const targetVersion = CONFIG_UPGRADER_CONSTANTS.CURRENT_VERSION;
|
||||
|
||||
if (currentVersion >= targetVersion) {
|
||||
console.log(`Config is up to date (version ${currentVersion})`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`Config upgrade needed: v${currentVersion} -> v${targetVersion}`);
|
||||
|
||||
let stepsExecuted = 0;
|
||||
|
||||
for (let nextVersion = currentVersion + 1; nextVersion <= targetVersion; nextVersion++) {
|
||||
switch (nextVersion) {
|
||||
case 1: {
|
||||
await upgradeConfigToV1();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`No config upgrader found for version ${nextVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the version after each successful step
|
||||
await KGConfigUpgrader.setConfigVersion(storage, nextVersion);
|
||||
stepsExecuted++;
|
||||
console.log(`Config upgraded to version ${nextVersion}`);
|
||||
}
|
||||
|
||||
return stepsExecuted;
|
||||
}
|
||||
|
||||
private static async getConfigVersion(storage: KGConfigStorage): Promise<number> {
|
||||
const raw = await storage.getRaw(CONFIG_UPGRADER_CONSTANTS.VERSION_KEY);
|
||||
if (!raw || typeof raw.version !== 'number') return 0;
|
||||
return raw.version;
|
||||
}
|
||||
|
||||
private static async setConfigVersion(storage: KGConfigStorage, version: number): Promise<void> {
|
||||
await storage.saveRaw(CONFIG_UPGRADER_CONSTANTS.VERSION_KEY, {
|
||||
version,
|
||||
upgradedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { instanceToPlain } from 'class-transformer';
|
||||
import { KGProject } from '../KGProject';
|
||||
|
||||
// --- Mock idb for reading old IndexedDB projects ---
|
||||
const mockProjects: Array<{ name: string; data: Record<string, unknown>; lastModified: number }> = [];
|
||||
|
||||
vi.mock('idb', () => {
|
||||
const stores: Record<string, Map<string, unknown>> = {};
|
||||
const getStore = (name: string) => {
|
||||
if (!stores[name]) stores[name] = new Map();
|
||||
return stores[name];
|
||||
};
|
||||
|
||||
return {
|
||||
openDB: vi.fn(() => {
|
||||
const db = {
|
||||
getAll: vi.fn((storeName: string) => {
|
||||
if (storeName === 'projects') return Promise.resolve([...mockProjects]);
|
||||
return Promise.resolve([...getStore(storeName).values()]);
|
||||
}),
|
||||
get: vi.fn((storeName: string, key: string) => getStore(storeName).get(key)),
|
||||
put: vi.fn((storeName: string, value: { name: string }) => {
|
||||
getStore(storeName).set(value.name, value);
|
||||
}),
|
||||
close: vi.fn(),
|
||||
objectStoreNames: { contains: () => false },
|
||||
};
|
||||
return Promise.resolve(db);
|
||||
}),
|
||||
__stores: stores,
|
||||
__reset: () => Object.keys(stores).forEach((k) => delete stores[k]),
|
||||
};
|
||||
});
|
||||
|
||||
// --- Mock OPFS for KGProjectStorage ---
|
||||
class MockWritable {
|
||||
data = '';
|
||||
async write(content: string) { this.data = content; }
|
||||
async close() {}
|
||||
}
|
||||
|
||||
class MockFileHandle {
|
||||
kind = 'file' as const;
|
||||
private _content = '';
|
||||
constructor(public name: string) {}
|
||||
async getFile() { return { text: () => Promise.resolve(this._content) }; }
|
||||
async createWritable() {
|
||||
const w = new MockWritable();
|
||||
const self = this;
|
||||
const origClose = w.close.bind(w);
|
||||
w.close = async () => { self._content = w.data; await origClose(); };
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
||||
class MockDirHandle {
|
||||
kind = 'directory' as const;
|
||||
entries = new Map<string, MockDirHandle | MockFileHandle>();
|
||||
constructor(public name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, opts?: { create?: boolean }) {
|
||||
let e = this.entries.get(name);
|
||||
if (!e || e.kind !== 'directory') {
|
||||
if (opts?.create) {
|
||||
e = new MockDirHandle(name);
|
||||
this.entries.set(name, e);
|
||||
} else {
|
||||
throw new DOMException('Not found', 'NotFoundError');
|
||||
}
|
||||
}
|
||||
return e as MockDirHandle;
|
||||
}
|
||||
|
||||
async getFileHandle(name: string, opts?: { create?: boolean }) {
|
||||
let e = this.entries.get(name);
|
||||
if (!e || e.kind !== 'file') {
|
||||
if (opts?.create) {
|
||||
e = new MockFileHandle(name);
|
||||
this.entries.set(name, e);
|
||||
} else {
|
||||
throw new DOMException('Not found', 'NotFoundError');
|
||||
}
|
||||
}
|
||||
return e as MockFileHandle;
|
||||
}
|
||||
|
||||
async removeEntry(name: string) { this.entries.delete(name); }
|
||||
|
||||
async *values() {
|
||||
for (const e of this.entries.values()) yield e;
|
||||
}
|
||||
}
|
||||
|
||||
const mockRoot = new MockDirHandle('root');
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
storage: {
|
||||
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
|
||||
persist: vi.fn(() => Promise.resolve(true)),
|
||||
},
|
||||
});
|
||||
|
||||
// Now import the module under test
|
||||
import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
||||
import { KGProjectStorage } from '../io/KGProjectStorage';
|
||||
|
||||
describe('upgradeConfigToV1', () => {
|
||||
beforeEach(async () => {
|
||||
mockProjects.length = 0;
|
||||
mockRoot.entries.clear();
|
||||
|
||||
// Reset KGProjectStorage singleton
|
||||
;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined;
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
await storage.initialize();
|
||||
});
|
||||
|
||||
it('migrates projects from IndexedDB to OPFS', async () => {
|
||||
// Set up a mock IndexedDB project
|
||||
const project = new KGProject('My Old Song', 32, 0, 130);
|
||||
mockProjects.push({
|
||||
name: 'My Old Song',
|
||||
data: instanceToPlain(project) as Record<string, unknown>,
|
||||
lastModified: 1700000000000,
|
||||
});
|
||||
|
||||
await upgradeConfigToV1();
|
||||
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const names = await storage.list();
|
||||
expect(names).toContain('My Old Song');
|
||||
|
||||
const loaded = await storage.load('My Old Song');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.getBpm()).toBe(130);
|
||||
});
|
||||
|
||||
it('sanitizes project names with invalid characters', async () => {
|
||||
const project = new KGProject('My:Song/Here', 32, 0, 120);
|
||||
mockProjects.push({
|
||||
name: 'My:Song/Here',
|
||||
data: instanceToPlain(project) as Record<string, unknown>,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
await upgradeConfigToV1();
|
||||
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const names = await storage.list();
|
||||
// Should be sanitized — no colons or slashes
|
||||
expect(names).toContain('My_Song_Here');
|
||||
});
|
||||
|
||||
it('skips projects that already exist in OPFS', async () => {
|
||||
// Pre-create a project in OPFS
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
const existing = new KGProject('Existing', 32, 0, 100);
|
||||
await storage.save('Existing', existing);
|
||||
|
||||
// Add same project to IndexedDB mock
|
||||
mockProjects.push({
|
||||
name: 'Existing',
|
||||
data: instanceToPlain(existing) as Record<string, unknown>,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
// Should not throw or overwrite
|
||||
await upgradeConfigToV1();
|
||||
|
||||
const loaded = await storage.load('Existing');
|
||||
expect(loaded!.getBpm()).toBe(100); // Original BPM preserved
|
||||
});
|
||||
|
||||
it('handles empty IndexedDB gracefully', async () => {
|
||||
// No projects in IndexedDB
|
||||
await expect(upgradeConfigToV1()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { openDB } from 'idb';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { KGProjectStorage } from '../io/KGProjectStorage';
|
||||
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
|
||||
import { sanitizeProjectName } from '../../util/projectNameUtil';
|
||||
import { DB_CONSTANTS } from '../../constants/coreConstants';
|
||||
|
||||
/**
|
||||
* Config upgrade V1: Migrate all projects from IndexedDB to OPFS.
|
||||
*
|
||||
* This reads directly from the old IndexedDB `projects` store (no dependency on KGStorage),
|
||||
* sanitizes project names, and writes each project to the OPFS-backed KGProjectStorage.
|
||||
*
|
||||
* Idempotent: projects already present in OPFS are skipped.
|
||||
* Non-destructive: old IndexedDB data is preserved as a backup.
|
||||
*/
|
||||
export async function upgradeConfigToV1(): Promise<void> {
|
||||
console.log('Config V1 upgrade: migrating projects from IndexedDB to OPFS...');
|
||||
|
||||
// Read all projects from the old IndexedDB store
|
||||
const oldProjects = await readAllProjectsFromIndexedDB();
|
||||
|
||||
if (oldProjects.length === 0) {
|
||||
console.log('Config V1 upgrade: no projects found in IndexedDB, nothing to migrate');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectStorage = KGProjectStorage.getInstance();
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const { name, data, lastModified } of oldProjects) {
|
||||
try {
|
||||
// Deserialize the project
|
||||
const instance = plainToInstance(KGProject, data);
|
||||
const project = Array.isArray(instance) ? instance[0] : instance;
|
||||
if (!project) {
|
||||
errors.push(`${name}: deserialization returned null`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sanitize the project name for filesystem use
|
||||
const sanitizedName = sanitizeProjectName(name);
|
||||
project.setName(sanitizedName);
|
||||
|
||||
// Run the project upgrader (handles schema changes like instrument mapping, etc.)
|
||||
const upgradedProject = upgradeProjectToLatest(project);
|
||||
|
||||
// Skip if already exists in OPFS
|
||||
if (await projectStorage.exists(sanitizedName)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save to OPFS — use overwrite=false since we checked exists() above
|
||||
// We call save directly which writes meta.json with createdAt = now.
|
||||
// Override createdAt with the original lastModified from IndexedDB afterwards.
|
||||
await projectStorage.save(sanitizedName, upgradedProject, false);
|
||||
|
||||
// Patch meta.json to use the original lastModified as createdAt
|
||||
if (lastModified) {
|
||||
await patchMetaCreatedAt(projectStorage, sanitizedName, lastModified);
|
||||
}
|
||||
|
||||
migrated++;
|
||||
} catch (error) {
|
||||
errors.push(`${name}: ${error}`);
|
||||
console.error(`Config V1 upgrade: failed to migrate project "${name}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Config V1 upgrade complete: ${migrated} migrated, ${skipped} skipped, ${errors.length} errors`,
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
console.warn('Config V1 upgrade errors:', errors);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
interface OldProjectEntry {
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all projects from the old IndexedDB store directly (no KGStorage dependency).
|
||||
*/
|
||||
async function readAllProjectsFromIndexedDB(): Promise<OldProjectEntry[]> {
|
||||
try {
|
||||
const db = await openDB(DB_CONSTANTS.DB_NAME, DB_CONSTANTS.DB_VERSION, {
|
||||
upgrade(db) {
|
||||
// Ensure stores exist (same logic as old KGStorage)
|
||||
const requiredStores = [DB_CONSTANTS.PROJECTS_STORE_NAME, DB_CONSTANTS.CONFIG_STORE_NAME];
|
||||
for (const store of requiredStores) {
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
db.createObjectStore(store, { keyPath: 'name' });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const allEntries = await db.getAll(DB_CONSTANTS.PROJECTS_STORE_NAME);
|
||||
db.close();
|
||||
|
||||
return allEntries.map((entry) => ({
|
||||
name: entry.name as string,
|
||||
data: entry.data as Record<string, unknown>,
|
||||
lastModified: (entry.lastModified as number) ?? Date.now(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Config V1 upgrade: failed to read from IndexedDB:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a project's meta.json to set createdAt to the original IndexedDB lastModified.
|
||||
* This is a best-effort operation — we access OPFS directly for this one-time patch.
|
||||
*/
|
||||
async function patchMetaCreatedAt(
|
||||
projectStorage: KGProjectStorage,
|
||||
projectName: string,
|
||||
createdAt: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const projectsDir = await root.getDirectoryHandle('projects');
|
||||
const projectDir = await projectsDir.getDirectoryHandle(projectName);
|
||||
const metaHandle = await projectDir.getFileHandle('meta.json');
|
||||
const file = await metaHandle.getFile();
|
||||
const meta = JSON.parse(await file.text());
|
||||
meta.createdAt = createdAt;
|
||||
const writable = await metaHandle.createWritable();
|
||||
await writable.write(JSON.stringify(meta, null, 2));
|
||||
await writable.close();
|
||||
} catch {
|
||||
// Non-critical — createdAt will just be the migration time
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { KGStorage } from '../io/KGStorage';
|
||||
import { DB_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { KGConfigStorage } from '../io/KGConfigStorage';
|
||||
|
||||
/**
|
||||
* Application configuration interface
|
||||
@@ -99,7 +98,7 @@ export class ConfigManager {
|
||||
|
||||
// Configuration state
|
||||
private config: AppConfig;
|
||||
private storage: KGStorage;
|
||||
private storage: KGConfigStorage;
|
||||
private isInitialized: boolean = false;
|
||||
private defaultConfig: AppConfig | null = null;
|
||||
private changeListeners: Set<(changedKeys: string[]) => void> = new Set();
|
||||
@@ -108,7 +107,7 @@ export class ConfigManager {
|
||||
private constructor() {
|
||||
// Initialize with empty config, will be loaded during initialize()
|
||||
this.config = {} as AppConfig;
|
||||
this.storage = KGStorage.getInstance();
|
||||
this.storage = KGConfigStorage.getInstance();
|
||||
console.log('ConfigManager initialized');
|
||||
}
|
||||
|
||||
@@ -262,11 +261,8 @@ export class ConfigManager {
|
||||
// For config, we don't use class-transformer since it's plain objects
|
||||
// So we'll use a simple object approach and handle it directly with KGStorage
|
||||
const savedConfigData = await this.storage.load(
|
||||
DB_CONSTANTS.DB_NAME,
|
||||
DB_CONSTANTS.CONFIG_STORE_NAME,
|
||||
ConfigManager.CONFIG_KEY,
|
||||
Object, // Simple object class
|
||||
DB_CONSTANTS.DB_VERSION
|
||||
);
|
||||
|
||||
if (savedConfigData) {
|
||||
@@ -294,12 +290,9 @@ export class ConfigManager {
|
||||
: this.config;
|
||||
|
||||
await this.storage.save(
|
||||
DB_CONSTANTS.DB_NAME,
|
||||
DB_CONSTANTS.CONFIG_STORE_NAME,
|
||||
ConfigManager.CONFIG_KEY,
|
||||
configToPersist,
|
||||
true, // Always overwrite config
|
||||
DB_CONSTANTS.DB_VERSION
|
||||
);
|
||||
console.log('Saved config to storage:', configToPersist);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock idb before importing KGConfigStorage
|
||||
vi.mock('idb', () => {
|
||||
const stores: Record<string, Map<string, unknown>> = {};
|
||||
|
||||
const getStore = (name: string): Map<string, unknown> => {
|
||||
if (!stores[name]) stores[name] = new Map();
|
||||
return stores[name];
|
||||
};
|
||||
|
||||
const mockDB = {
|
||||
get: vi.fn((storeName: string, key: string) => {
|
||||
return getStore(storeName).get(key) ?? undefined;
|
||||
}),
|
||||
put: vi.fn((storeName: string, value: { name: string }) => {
|
||||
getStore(storeName).set(value.name, value);
|
||||
}),
|
||||
delete: vi.fn((storeName: string, key: string) => {
|
||||
getStore(storeName).delete(key);
|
||||
}),
|
||||
objectStoreNames: { contains: () => false },
|
||||
};
|
||||
|
||||
return {
|
||||
openDB: vi.fn(() => Promise.resolve(mockDB)),
|
||||
__stores: stores,
|
||||
__reset: () => {
|
||||
Object.keys(stores).forEach((k) => delete stores[k]);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { KGConfigStorage } from './KGConfigStorage';
|
||||
|
||||
// Access mock internals
|
||||
const idbMock = await import('idb') as unknown as {
|
||||
__stores: Record<string, Map<string, unknown>>;
|
||||
__reset: () => void;
|
||||
};
|
||||
|
||||
describe('KGConfigStorage', () => {
|
||||
let storage: KGConfigStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
idbMock.__reset();
|
||||
// Reset singleton for test isolation
|
||||
;(KGConfigStorage as unknown as { _instance: undefined })._instance = undefined;
|
||||
storage = KGConfigStorage.getInstance();
|
||||
});
|
||||
|
||||
it('saves and loads a config entry', async () => {
|
||||
await storage.save('testKey', { foo: 'bar' }, true);
|
||||
const result = await storage.load('testKey', Object);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect((result as Record<string, unknown>).foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('deletes a config entry', async () => {
|
||||
await storage.save('toDelete', { x: 1 }, true);
|
||||
await storage.delete('toDelete');
|
||||
const result = await storage.load('toDelete', Object);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('saveRaw and getRaw work for version markers', async () => {
|
||||
await storage.saveRaw('__config_version', { version: 1, upgradedAt: 123 });
|
||||
const raw = await storage.getRaw('__config_version');
|
||||
|
||||
expect(raw).toBeDefined();
|
||||
expect(raw!.version).toBe(1);
|
||||
});
|
||||
|
||||
it('getRaw returns null for non-existent key', async () => {
|
||||
const result = await storage.getRaw('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns singleton instance', () => {
|
||||
const a = KGConfigStorage.getInstance();
|
||||
const b = KGConfigStorage.getInstance();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { openDB } from 'idb';
|
||||
import type { IDBPDatabase } from 'idb';
|
||||
import { instanceToPlain, plainToInstance } from 'class-transformer';
|
||||
import { DB_CONSTANTS } from '../../constants/coreConstants';
|
||||
|
||||
interface ConfigStorageEntry {
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* KGConfigStorage — IndexedDB-backed storage for application configuration.
|
||||
* Extracted from the former KGStorage class; only manages the config object store.
|
||||
*/
|
||||
export class KGConfigStorage {
|
||||
private static _instance: KGConfigStorage;
|
||||
private dbPromise: Promise<IDBPDatabase> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): KGConfigStorage {
|
||||
if (!KGConfigStorage._instance) {
|
||||
KGConfigStorage._instance = new KGConfigStorage();
|
||||
}
|
||||
return KGConfigStorage._instance;
|
||||
}
|
||||
|
||||
private getDB(): Promise<IDBPDatabase> {
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = openDB(DB_CONSTANTS.DB_NAME, DB_CONSTANTS.DB_VERSION, {
|
||||
upgrade(db) {
|
||||
// Create required object stores if they don't exist
|
||||
const requiredStores = [
|
||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
||||
DB_CONSTANTS.CONFIG_STORE_NAME,
|
||||
];
|
||||
for (const store of requiredStores) {
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
db.createObjectStore(store, { keyPath: 'name' });
|
||||
console.log(`Created object store: ${store}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
public async save(name: string, data: unknown, overwrite: boolean = true): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const storeName = DB_CONSTANTS.CONFIG_STORE_NAME;
|
||||
|
||||
if (!overwrite) {
|
||||
const existing = await db.get(storeName, name);
|
||||
if (existing) {
|
||||
throw new Error(`Config entry "${name}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const entry: ConfigStorageEntry = {
|
||||
name,
|
||||
data: instanceToPlain(data) as Record<string, unknown>,
|
||||
lastModified: Date.now(),
|
||||
};
|
||||
await db.put(storeName, entry);
|
||||
}
|
||||
|
||||
public async load<T>(name: string, classType: new () => T): Promise<T | null> {
|
||||
try {
|
||||
const db = await this.getDB();
|
||||
const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name);
|
||||
|
||||
if (!entry?.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const instance = plainToInstance(classType, entry.data);
|
||||
return Array.isArray(instance) ? instance[0] || null : instance;
|
||||
} catch (error) {
|
||||
console.error(`Error loading config entry "${name}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(name: string): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
await db.delete(DB_CONSTANTS.CONFIG_STORE_NAME, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a raw value from the config store (no class-transformer deserialization).
|
||||
* Used by KGConfigUpgrader to read the config version marker.
|
||||
*/
|
||||
public async getRaw(name: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const db = await this.getDB();
|
||||
const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name);
|
||||
return entry?.data ?? null;
|
||||
} catch (error) {
|
||||
console.error(`Error loading raw config entry "${name}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a raw value to the config store (no class-transformer serialization).
|
||||
* Used by KGConfigUpgrader to write the config version marker.
|
||||
*/
|
||||
public async saveRaw(name: string, data: Record<string, unknown>): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const entry: ConfigStorageEntry = {
|
||||
name,
|
||||
data,
|
||||
lastModified: Date.now(),
|
||||
};
|
||||
await db.put(DB_CONSTANTS.CONFIG_STORE_NAME, entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage';
|
||||
import { KGProject } from '../KGProject';
|
||||
|
||||
// --- OPFS mock infrastructure ---
|
||||
|
||||
class MockFileSystemWritableFileStream {
|
||||
public data = '';
|
||||
async write(content: string) { this.data = content; }
|
||||
async close() {}
|
||||
}
|
||||
|
||||
class MockFileSystemFileHandle {
|
||||
kind = 'file' as const;
|
||||
constructor(public name: string, private _content: string = '') {}
|
||||
async getFile() {
|
||||
return { text: () => Promise.resolve(this._content) };
|
||||
}
|
||||
async createWritable() {
|
||||
const stream = new MockFileSystemWritableFileStream();
|
||||
// When stream closes, update our content
|
||||
const self = this;
|
||||
const origClose = stream.close.bind(stream);
|
||||
stream.close = async () => {
|
||||
self._content = stream.data;
|
||||
await origClose();
|
||||
};
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
class MockFileSystemDirectoryHandle {
|
||||
kind = 'directory' as const;
|
||||
private entries = new Map<string, MockFileSystemDirectoryHandle | MockFileSystemFileHandle>();
|
||||
|
||||
constructor(public name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemDirectoryHandle> {
|
||||
let entry = this.entries.get(name);
|
||||
if (!entry || entry.kind !== 'directory') {
|
||||
if (options?.create) {
|
||||
entry = new MockFileSystemDirectoryHandle(name);
|
||||
this.entries.set(name, entry);
|
||||
} else {
|
||||
throw new DOMException(`Directory "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
}
|
||||
return entry as MockFileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
async getFileHandle(name: string, options?: { create?: boolean }): Promise<MockFileSystemFileHandle> {
|
||||
let entry = this.entries.get(name);
|
||||
if (!entry || entry.kind !== 'file') {
|
||||
if (options?.create) {
|
||||
entry = new MockFileSystemFileHandle(name);
|
||||
this.entries.set(name, entry);
|
||||
} else {
|
||||
throw new DOMException(`File "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
}
|
||||
return entry as MockFileSystemFileHandle;
|
||||
}
|
||||
|
||||
async removeEntry(name: string, _options?: { recursive?: boolean }): Promise<void> {
|
||||
if (!this.entries.has(name)) {
|
||||
throw new DOMException(`Entry "${name}" not found`, 'NotFoundError');
|
||||
}
|
||||
this.entries.delete(name);
|
||||
}
|
||||
|
||||
async *values(): AsyncIterableIterator<MockFileSystemDirectoryHandle | MockFileSystemFileHandle> {
|
||||
for (const entry of this.entries.values()) {
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Install the mock
|
||||
const mockRoot = new MockFileSystemDirectoryHandle('root');
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
storage: {
|
||||
getDirectory: vi.fn(() => Promise.resolve(mockRoot)),
|
||||
persist: vi.fn(() => Promise.resolve(true)),
|
||||
estimate: vi.fn(() => Promise.resolve({ usage: 0, quota: 1e9 })),
|
||||
},
|
||||
});
|
||||
|
||||
describe('KGProjectStorage', () => {
|
||||
let storage: KGProjectStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset singleton and mock filesystem
|
||||
;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined;
|
||||
// Clear the mock root directory entries
|
||||
const entries = (mockRoot as unknown as { entries: Map<string, unknown> }).entries;
|
||||
entries.clear();
|
||||
|
||||
storage = KGProjectStorage.getInstance();
|
||||
await storage.initialize();
|
||||
});
|
||||
|
||||
function createTestProject(name = 'Test Project'): KGProject {
|
||||
return new KGProject(name, 16, 0, 120);
|
||||
}
|
||||
|
||||
it('initializes and creates the projects directory', async () => {
|
||||
// The projects directory should exist after init
|
||||
const projects = await mockRoot.getDirectoryHandle('projects');
|
||||
expect(projects).toBeDefined();
|
||||
expect(projects.kind).toBe('directory');
|
||||
});
|
||||
|
||||
it('saves and loads a project', async () => {
|
||||
const project = createTestProject('My Song');
|
||||
await storage.save('My Song', project);
|
||||
|
||||
const loaded = await storage.load('My Song');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.getName()).toBe('My Song');
|
||||
expect(loaded!.getBpm()).toBe(120);
|
||||
});
|
||||
|
||||
it('creates meta.json and media/ directory on save', async () => {
|
||||
const project = createTestProject('My Song');
|
||||
await storage.save('My Song', project);
|
||||
|
||||
const projectsDir = await mockRoot.getDirectoryHandle('projects');
|
||||
const projectDir = await projectsDir.getDirectoryHandle('My Song');
|
||||
|
||||
// meta.json should exist
|
||||
const metaHandle = await projectDir.getFileHandle('meta.json');
|
||||
const metaFile = await metaHandle.getFile();
|
||||
const meta = JSON.parse(await metaFile.text());
|
||||
expect(meta.name).toBe('My Song');
|
||||
expect(meta.createdAt).toBeGreaterThan(0);
|
||||
expect(meta.updatedAt).toBeGreaterThan(0);
|
||||
|
||||
// media/ directory should exist
|
||||
const mediaDir = await projectDir.getDirectoryHandle('media');
|
||||
expect(mediaDir.kind).toBe('directory');
|
||||
});
|
||||
|
||||
it('throws DuplicateEntryError when overwrite is false', async () => {
|
||||
const project = createTestProject('Duplicate');
|
||||
await storage.save('Duplicate', project);
|
||||
|
||||
await expect(storage.save('Duplicate', project, false)).rejects.toThrow(DuplicateEntryError);
|
||||
});
|
||||
|
||||
it('allows overwrite when overwrite is true', async () => {
|
||||
const project = createTestProject('Overwrite Test');
|
||||
await storage.save('Overwrite Test', project);
|
||||
|
||||
project.setBpm(140);
|
||||
await storage.save('Overwrite Test', project, true);
|
||||
|
||||
const loaded = await storage.load('Overwrite Test');
|
||||
expect(loaded!.getBpm()).toBe(140);
|
||||
});
|
||||
|
||||
it('preserves createdAt on overwrite', async () => {
|
||||
const project = createTestProject('Preserve');
|
||||
await storage.save('Preserve', project);
|
||||
|
||||
// Read the original createdAt
|
||||
const projectsDir = await mockRoot.getDirectoryHandle('projects');
|
||||
const projectDir = await projectsDir.getDirectoryHandle('Preserve');
|
||||
const metaHandle1 = await projectDir.getFileHandle('meta.json');
|
||||
const meta1 = JSON.parse(await (await metaHandle1.getFile()).text());
|
||||
|
||||
// Save again (overwrite)
|
||||
await storage.save('Preserve', project, true);
|
||||
|
||||
const metaHandle2 = await projectDir.getFileHandle('meta.json');
|
||||
const meta2 = JSON.parse(await (await metaHandle2.getFile()).text());
|
||||
|
||||
expect(meta2.createdAt).toBe(meta1.createdAt);
|
||||
expect(meta2.updatedAt).toBeGreaterThanOrEqual(meta1.updatedAt);
|
||||
});
|
||||
|
||||
it('lists project names', async () => {
|
||||
await storage.save('Alpha', createTestProject('Alpha'));
|
||||
await storage.save('Beta', createTestProject('Beta'));
|
||||
await storage.save('Charlie', createTestProject('Charlie'));
|
||||
|
||||
const names = await storage.list();
|
||||
expect(names).toEqual(['Alpha', 'Beta', 'Charlie']);
|
||||
});
|
||||
|
||||
it('checks if project exists', async () => {
|
||||
expect(await storage.exists('Nonexistent')).toBe(false);
|
||||
|
||||
await storage.save('Exists', createTestProject('Exists'));
|
||||
expect(await storage.exists('Exists')).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes a project', async () => {
|
||||
await storage.save('ToDelete', createTestProject('ToDelete'));
|
||||
expect(await storage.exists('ToDelete')).toBe(true);
|
||||
|
||||
await storage.delete('ToDelete');
|
||||
expect(await storage.exists('ToDelete')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null for non-existent project on load', async () => {
|
||||
const result = await storage.load('Ghost');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid project names on save', async () => {
|
||||
const project = createTestProject();
|
||||
await expect(storage.save('my/song', project)).rejects.toThrow('Invalid project name');
|
||||
await expect(storage.save('my:song', project)).rejects.toThrow('Invalid project name');
|
||||
await expect(storage.save('', project)).rejects.toThrow('Invalid project name');
|
||||
});
|
||||
|
||||
it('renames a project', async () => {
|
||||
await storage.save('Old Name', createTestProject('Old Name'));
|
||||
|
||||
await storage.rename('Old Name', 'New Name');
|
||||
|
||||
expect(await storage.exists('Old Name')).toBe(false);
|
||||
expect(await storage.exists('New Name')).toBe(true);
|
||||
|
||||
const loaded = await storage.load('New Name');
|
||||
expect(loaded!.getName()).toBe('New Name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
import { instanceToPlain, plainToInstance } from 'class-transformer';
|
||||
import JSZip from 'jszip';
|
||||
import { KGProject } from '../KGProject';
|
||||
import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader';
|
||||
import { isValidProjectName } from '../../util/projectNameUtil';
|
||||
import { OPFS_CONSTANTS } from '../../constants/coreConstants';
|
||||
|
||||
export class DuplicateEntryError extends Error {
|
||||
constructor(name: string) {
|
||||
super(`Entry "${name}" already exists`);
|
||||
this.name = 'DuplicateEntryError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ProjectMeta {
|
||||
name: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* KGProjectStorage — OPFS-backed storage for project files.
|
||||
* Each project lives in its own directory under the OPFS `projects/` root.
|
||||
*
|
||||
* Folder structure:
|
||||
* projects/<ProjectName>/meta.json
|
||||
* projects/<ProjectName>/project.json
|
||||
* projects/<ProjectName>/media/
|
||||
*/
|
||||
export class KGProjectStorage {
|
||||
private static _instance: KGProjectStorage;
|
||||
private rootDirHandle: FileSystemDirectoryHandle | null = null;
|
||||
private projectsDirHandle: FileSystemDirectoryHandle | null = null;
|
||||
private _initialized = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): KGProjectStorage {
|
||||
if (!KGProjectStorage._instance) {
|
||||
KGProjectStorage._instance = new KGProjectStorage();
|
||||
}
|
||||
return KGProjectStorage._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize OPFS root and request persistent storage.
|
||||
* Must be called before any other method.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this._initialized) return;
|
||||
|
||||
this.rootDirHandle = await navigator.storage.getDirectory();
|
||||
this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle(
|
||||
OPFS_CONSTANTS.ROOT_DIR,
|
||||
{ create: true },
|
||||
);
|
||||
|
||||
// Request persistent storage so the browser won't evict our data
|
||||
try {
|
||||
const persisted = await navigator.storage.persist();
|
||||
console.log(`Persistent storage ${persisted ? 'granted' : 'denied'}`);
|
||||
} catch (error) {
|
||||
console.warn('navigator.storage.persist() not available:', error);
|
||||
}
|
||||
|
||||
this._initialized = true;
|
||||
console.log('KGProjectStorage initialized (OPFS)');
|
||||
}
|
||||
|
||||
private ensureInitialized(): void {
|
||||
if (!this._initialized || !this.projectsDirHandle) {
|
||||
throw new Error('KGProjectStorage not initialized. Call initialize() first.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a project. Creates the folder structure and writes meta.json + project.json.
|
||||
*/
|
||||
public async save(name: string, data: KGProject, overwrite: boolean = false): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
|
||||
if (!isValidProjectName(name)) {
|
||||
throw new Error(
|
||||
`Invalid project name "${name}". Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const exists = await this.exists(name);
|
||||
if (exists && !overwrite) {
|
||||
throw new DuplicateEntryError(name);
|
||||
}
|
||||
|
||||
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name, { create: true });
|
||||
|
||||
// Ensure media/ directory exists
|
||||
await projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true });
|
||||
|
||||
// Write project.json
|
||||
const projectData = instanceToPlain(data) as Record<string, unknown>;
|
||||
const projectJson = JSON.stringify(projectData, null, 2);
|
||||
await this.writeFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE, projectJson);
|
||||
|
||||
// Write/update meta.json
|
||||
const now = Date.now();
|
||||
let meta: ProjectMeta;
|
||||
try {
|
||||
const existingMeta = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE);
|
||||
const parsed = JSON.parse(existingMeta) as ProjectMeta;
|
||||
meta = { name, createdAt: parsed.createdAt, updatedAt: now };
|
||||
} catch {
|
||||
meta = { name, createdAt: now, updatedAt: now };
|
||||
}
|
||||
await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a project by name. Runs the project upgrader on the loaded data.
|
||||
*/
|
||||
public async load(name: string): Promise<KGProject | null> {
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
|
||||
const projectJson = await this.readFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE);
|
||||
const plainData = JSON.parse(projectJson);
|
||||
|
||||
const instance = plainToInstance(KGProject, plainData);
|
||||
const project = Array.isArray(instance) ? instance[0] || null : instance;
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
project.setName(name);
|
||||
return upgradeProjectToLatest(project);
|
||||
} catch (error) {
|
||||
console.error(`Error loading project "${name}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all project names (folder names under projects/).
|
||||
*/
|
||||
public async list(): Promise<string[]> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const names: string[] = [];
|
||||
// FileSystemDirectoryHandle.entries() returns AsyncIterableIterator
|
||||
// TypeScript's lib.dom.d.ts may lack full typing for this, so we iterate via values()
|
||||
for await (const entry of this.projectsDirHandle!.values()) {
|
||||
if (entry.kind === 'directory') {
|
||||
names.push(entry.name);
|
||||
}
|
||||
}
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project and all its files.
|
||||
*/
|
||||
public async delete(name: string): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
await this.projectsDirHandle!.removeEntry(name, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting project "${name}":`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project exists.
|
||||
*/
|
||||
public async exists(name: string): Promise<boolean> {
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
await this.projectsDirHandle!.getDirectoryHandle(name);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a project by copying its directory contents to a new name and deleting the old one.
|
||||
*/
|
||||
public async rename(oldName: string, newName: string): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
|
||||
if (!isValidProjectName(newName)) {
|
||||
throw new Error(`Invalid project name "${newName}".`);
|
||||
}
|
||||
|
||||
if (await this.exists(newName)) {
|
||||
throw new DuplicateEntryError(newName);
|
||||
}
|
||||
|
||||
// Load the project from the old location
|
||||
const project = await this.load(oldName);
|
||||
if (!project) {
|
||||
throw new Error(`Project "${oldName}" not found.`);
|
||||
}
|
||||
|
||||
// Save to new location
|
||||
project.setName(newName);
|
||||
await this.save(newName, project, false);
|
||||
|
||||
// Delete old location
|
||||
await this.delete(oldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a project folder as a zip Blob (.kgstudio bundle).
|
||||
* Includes project.json, meta.json, and all files in media/.
|
||||
*/
|
||||
public async exportAsZip(name: string): Promise<Blob> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name);
|
||||
const zip = new JSZip();
|
||||
|
||||
await this.addDirectoryToZip(zip, projectDir);
|
||||
|
||||
return zip.generateAsync({ type: 'blob' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add all files and subdirectories from an OPFS directory to a JSZip instance.
|
||||
*/
|
||||
private async addDirectoryToZip(
|
||||
zip: JSZip,
|
||||
dirHandle: FileSystemDirectoryHandle,
|
||||
path: string = '',
|
||||
): Promise<void> {
|
||||
for await (const entry of dirHandle.values()) {
|
||||
const entryPath = path ? `${path}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.kind === 'file') {
|
||||
const fileHandle = entry as FileSystemFileHandle;
|
||||
const file = await fileHandle.getFile();
|
||||
zip.file(entryPath, file.arrayBuffer());
|
||||
} else {
|
||||
const subDir = entry as FileSystemDirectoryHandle;
|
||||
await this.addDirectoryToZip(zip, subDir, entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a .kgstudio zip bundle into OPFS.
|
||||
* Validates that meta.json exists and is valid.
|
||||
* Returns the project name on success.
|
||||
* On failure, cleans up any partially written folder and throws.
|
||||
*/
|
||||
public async importFromZip(blob: Blob): Promise<string> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const zip = await JSZip.loadAsync(blob);
|
||||
|
||||
// Validate meta.json
|
||||
const metaFile = zip.file(OPFS_CONSTANTS.METADATA_FILE);
|
||||
if (!metaFile) {
|
||||
throw new Error('Invalid .kgstudio file: missing meta.json');
|
||||
}
|
||||
|
||||
let meta: { name?: string };
|
||||
try {
|
||||
const metaText = await metaFile.async('text');
|
||||
meta = JSON.parse(metaText);
|
||||
} catch {
|
||||
throw new Error('Invalid .kgstudio file: meta.json is corrupted');
|
||||
}
|
||||
|
||||
if (!meta.name || typeof meta.name !== 'string') {
|
||||
throw new Error('Invalid .kgstudio file: meta.json missing project name');
|
||||
}
|
||||
|
||||
const projectName = await this.resolveUniqueName(meta.name);
|
||||
|
||||
const projectDir = await this.projectsDirHandle!.getDirectoryHandle(projectName, { create: true });
|
||||
|
||||
try {
|
||||
// Write all files from the zip into the OPFS project directory
|
||||
for (const [relativePath, zipEntry] of Object.entries(zip.files)) {
|
||||
if (zipEntry.dir) {
|
||||
// Create subdirectory
|
||||
await this.getOrCreateSubDir(projectDir, relativePath);
|
||||
} else {
|
||||
// Write file
|
||||
const data = await zipEntry.async('arraybuffer');
|
||||
const parts = relativePath.split('/');
|
||||
const fileName = parts.pop()!;
|
||||
|
||||
let targetDir = projectDir;
|
||||
if (parts.length > 0) {
|
||||
targetDir = await this.getOrCreateSubDir(projectDir, parts.join('/'));
|
||||
}
|
||||
|
||||
const fileHandle = await targetDir.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
}
|
||||
}
|
||||
|
||||
// If the name was deduplicated, update meta.json and project.json to reflect it
|
||||
if (projectName !== meta.name) {
|
||||
// Patch meta.json
|
||||
try {
|
||||
const metaHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.METADATA_FILE);
|
||||
const metaFileObj = await metaHandle.getFile();
|
||||
const metaData = JSON.parse(await metaFileObj.text());
|
||||
metaData.name = projectName;
|
||||
const w1 = await metaHandle.createWritable();
|
||||
await w1.write(JSON.stringify(metaData, null, 2));
|
||||
await w1.close();
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Patch project.json name field
|
||||
try {
|
||||
const projHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.PROJECT_FILE);
|
||||
const projFileObj = await projHandle.getFile();
|
||||
const projData = JSON.parse(await projFileObj.text());
|
||||
projData.name = projectName;
|
||||
const w2 = await projHandle.createWritable();
|
||||
await w2.write(JSON.stringify(projData, null, 2));
|
||||
await w2.close();
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
return projectName;
|
||||
} catch (error) {
|
||||
// Clean up the partially written folder
|
||||
try {
|
||||
await this.projectsDirHandle!.removeEntry(projectName, { recursive: true });
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a nested subdirectory from a path like "media/subfolder".
|
||||
*/
|
||||
private async getOrCreateSubDir(
|
||||
root: FileSystemDirectoryHandle,
|
||||
path: string,
|
||||
): Promise<FileSystemDirectoryHandle> {
|
||||
const segments = path.replace(/\/$/, '').split('/').filter(Boolean);
|
||||
let current = root;
|
||||
for (const seg of segments) {
|
||||
current = await current.getDirectoryHandle(seg, { create: true });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a unique project name by appending (1), (2), etc. if the name already exists.
|
||||
*/
|
||||
public async resolveUniqueName(name: string): Promise<string> {
|
||||
this.ensureInitialized();
|
||||
|
||||
if (!(await this.exists(name))) return name;
|
||||
|
||||
let counter = 1;
|
||||
let candidate: string;
|
||||
do {
|
||||
candidate = `${name} (${counter})`;
|
||||
counter++;
|
||||
} while (await this.exists(candidate));
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// --- File I/O helpers ---
|
||||
|
||||
private async writeFile(
|
||||
dirHandle: FileSystemDirectoryHandle,
|
||||
fileName: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
private async readFile(
|
||||
dirHandle: FileSystemDirectoryHandle,
|
||||
fileName: string,
|
||||
): Promise<string> {
|
||||
const fileHandle = await dirHandle.getFileHandle(fileName);
|
||||
const file = await fileHandle.getFile();
|
||||
return file.text();
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// src/core/io/KGStorage.ts
|
||||
|
||||
import { openDB } from 'idb'
|
||||
import type { IDBPDatabase } from 'idb'
|
||||
import { plainToInstance, instanceToPlain } from 'class-transformer'
|
||||
import { DB_CONSTANTS } from '../../constants/coreConstants'
|
||||
|
||||
export interface StorageEntry {
|
||||
name: string
|
||||
data: Record<string, unknown>
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
export class DuplicateEntryError extends Error {
|
||||
constructor(name: string) {
|
||||
super(`Entry "${name}" already exists`)
|
||||
this.name = 'DuplicateEntryError'
|
||||
}
|
||||
}
|
||||
|
||||
export class KGStorage {
|
||||
private static instance: KGStorage
|
||||
private dbPromises: Map<string, Promise<IDBPDatabase>>
|
||||
|
||||
private constructor() {
|
||||
this.dbPromises = new Map()
|
||||
}
|
||||
|
||||
public static getInstance(): KGStorage {
|
||||
if (!KGStorage.instance) {
|
||||
KGStorage.instance = new KGStorage()
|
||||
}
|
||||
return KGStorage.instance
|
||||
}
|
||||
|
||||
private getDB(dbName: string, _storeName: string, version: number = 1): Promise<IDBPDatabase> {
|
||||
const key = `${dbName}_${version}`
|
||||
|
||||
if (!this.dbPromises.has(key)) {
|
||||
const dbPromise = openDB(dbName, version, {
|
||||
upgrade(db) {
|
||||
// Create all required object stores for this database
|
||||
const requiredStores = [
|
||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
||||
DB_CONSTANTS.CONFIG_STORE_NAME
|
||||
];
|
||||
|
||||
for (const store of requiredStores) {
|
||||
if (!db.objectStoreNames.contains(store)) {
|
||||
db.createObjectStore(store, { keyPath: 'name' })
|
||||
console.log(`Created object store: ${store}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
this.dbPromises.set(key, dbPromise)
|
||||
}
|
||||
|
||||
return this.dbPromises.get(key)!
|
||||
}
|
||||
|
||||
public async save<T>(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
name: string,
|
||||
data: T,
|
||||
overwrite: boolean = false,
|
||||
version: number = 1
|
||||
): Promise<void> {
|
||||
const db = await this.getDB(dbName, storeName, version)
|
||||
const existing = await db.get(storeName, name)
|
||||
if (existing && !overwrite) {
|
||||
throw new DuplicateEntryError(name)
|
||||
}
|
||||
const entry: StorageEntry = {
|
||||
name: name,
|
||||
data: instanceToPlain(data) as Record<string, unknown>,
|
||||
lastModified: Date.now(),
|
||||
}
|
||||
await db.put(storeName, entry)
|
||||
}
|
||||
|
||||
public async load<T>(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
name: string,
|
||||
classType: new() => T,
|
||||
version: number = 1
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const db = await this.getDB(dbName, storeName, version)
|
||||
const entry = await db.get(storeName, name)
|
||||
|
||||
if (!entry?.data) {
|
||||
console.log(`No data found for entry "${name}" in store "${storeName}" of database "${dbName}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
const instance = plainToInstance(classType, entry.data)
|
||||
const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance
|
||||
|
||||
if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') {
|
||||
(loadedInstance as { setName: (projectName: string) => void }).setName(name)
|
||||
}
|
||||
|
||||
return loadedInstance
|
||||
} catch (error) {
|
||||
console.log(`Error loading entry "${name}" from store "${storeName}" of database "${dbName}":`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public async list(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
version: number = 1
|
||||
): Promise<string[]> {
|
||||
const db = await this.getDB(dbName, storeName, version)
|
||||
const all = await db.getAllKeys(storeName)
|
||||
return all as string[]
|
||||
}
|
||||
|
||||
public async delete(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
name: string,
|
||||
version: number = 1
|
||||
): Promise<void> {
|
||||
const db = await this.getDB(dbName, storeName, version)
|
||||
await db.delete(storeName, name)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,12 @@ button:focus-visible {
|
||||
outline: 2px auto rgba(100, 108, 255, 0.5);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'reflect-metadata';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles/variables.css';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import { KGCore } from './core/KGCore';
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/* Shared toolbar layout classes (used by Toolbar and PianoRollToolbar) */
|
||||
|
||||
.toolbar-left, .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
width: 20%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
width: 33%;
|
||||
justify-content: flex-end;
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1005;
|
||||
margin-left: -50px;
|
||||
}
|
||||
|
||||
/* Tool buttons (used by Toolbar and PianoRollToolbar) */
|
||||
|
||||
.tool-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.tool-button:hover {
|
||||
background-color: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.tool-button.active {
|
||||
background-color: #4a4a4a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Quantization dropdown system (used by KGDropdown) */
|
||||
|
||||
.quant-button {
|
||||
background-color: #333;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.quant-button:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.quant-dropdown-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
.quant-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
width: 100px;
|
||||
z-index: 1500;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
margin-top: 2px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quant-option {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.quant-option:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.quant-option.active {
|
||||
background-color: #4a6b8a;
|
||||
}
|
||||
|
||||
/* Generic button blink effect */
|
||||
.quant-button.button-blink {
|
||||
animation: buttonBlink 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes buttonBlink {
|
||||
0% { background-color: #333; }
|
||||
50% { background-color: #555; }
|
||||
100% { background-color: #333; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
:root {
|
||||
--time-signature-numerator: 4;
|
||||
--max-number-of-bars: 32;
|
||||
--track-grid-bar-width: 40px;
|
||||
--region-piano-key-width: 60px;
|
||||
--region-piano-key-height: 20px;
|
||||
--region-grid-beat-width: 40px;
|
||||
--region-grid-bar-width: calc(var(--region-grid-beat-width) * var(--time-signature-numerator));
|
||||
--chat-box-width: 350px;
|
||||
--instrument-selection-width: 300px;
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Type augmentations for the Origin Private File System (OPFS) async iterable APIs.
|
||||
* These are part of the File System Access API but not yet fully typed in TypeScript's lib.dom.d.ts.
|
||||
*/
|
||||
interface FileSystemDirectoryHandle {
|
||||
values(): AsyncIterableIterator<FileSystemDirectoryHandle | FileSystemFileHandle>;
|
||||
keys(): AsyncIterableIterator<string>;
|
||||
entries(): AsyncIterableIterator<[string, FileSystemDirectoryHandle | FileSystemFileHandle]>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidProjectName, sanitizeProjectName } from './projectNameUtil';
|
||||
|
||||
describe('isValidProjectName', () => {
|
||||
it('accepts simple alphanumeric names', () => {
|
||||
expect(isValidProjectName('MyProject')).toBe(true);
|
||||
expect(isValidProjectName('project123')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts names with allowed special characters', () => {
|
||||
expect(isValidProjectName('My Song')).toBe(true);
|
||||
expect(isValidProjectName('song-v2')).toBe(true);
|
||||
expect(isValidProjectName('song_final')).toBe(true);
|
||||
expect(isValidProjectName('song.backup')).toBe(true);
|
||||
expect(isValidProjectName('Song (v2)')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty or whitespace-only names', () => {
|
||||
expect(isValidProjectName('')).toBe(false);
|
||||
expect(isValidProjectName(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects names starting with a dot', () => {
|
||||
expect(isValidProjectName('.hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects names with disallowed characters', () => {
|
||||
expect(isValidProjectName('my/song')).toBe(false);
|
||||
expect(isValidProjectName('my\\song')).toBe(false);
|
||||
expect(isValidProjectName('my:song')).toBe(false);
|
||||
expect(isValidProjectName('my*song')).toBe(false);
|
||||
expect(isValidProjectName('my?song')).toBe(false);
|
||||
expect(isValidProjectName('my"song')).toBe(false);
|
||||
expect(isValidProjectName('my<song')).toBe(false);
|
||||
expect(isValidProjectName('my>song')).toBe(false);
|
||||
expect(isValidProjectName('my|song')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts accented characters', () => {
|
||||
expect(isValidProjectName('Café Waltz')).toBe(true);
|
||||
expect(isValidProjectName('Ñoño')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeProjectName', () => {
|
||||
it('returns valid names unchanged', () => {
|
||||
expect(sanitizeProjectName('My Song')).toBe('My Song');
|
||||
expect(sanitizeProjectName('project-123')).toBe('project-123');
|
||||
});
|
||||
|
||||
it('replaces disallowed characters with underscores', () => {
|
||||
expect(sanitizeProjectName('my/song')).toBe('my_song');
|
||||
expect(sanitizeProjectName('my:song')).toBe('my_song');
|
||||
expect(sanitizeProjectName('a*b?c')).toBe('a_b_c');
|
||||
});
|
||||
|
||||
it('collapses consecutive underscores', () => {
|
||||
expect(sanitizeProjectName('a///b')).toBe('a_b');
|
||||
expect(sanitizeProjectName('a__b')).toBe('a_b');
|
||||
});
|
||||
|
||||
it('collapses consecutive spaces', () => {
|
||||
expect(sanitizeProjectName('a b')).toBe('a b');
|
||||
});
|
||||
|
||||
it('trims leading/trailing whitespace, underscores, and dots', () => {
|
||||
expect(sanitizeProjectName(' My Song ')).toBe('My Song');
|
||||
expect(sanitizeProjectName('__song__')).toBe('song');
|
||||
expect(sanitizeProjectName('.hidden')).toBe('hidden');
|
||||
expect(sanitizeProjectName('...dots...')).toBe('dots');
|
||||
});
|
||||
|
||||
it('returns fallback for names that become empty after sanitization', () => {
|
||||
expect(sanitizeProjectName('///')).toBe('Untitled Project');
|
||||
expect(sanitizeProjectName('...')).toBe('Untitled Project');
|
||||
expect(sanitizeProjectName('___')).toBe('Untitled Project');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Allowed characters for project names: letters, numbers, space, hyphen, underscore, period, parentheses.
|
||||
* These are safe across Windows, macOS, and Linux as directory names.
|
||||
*/
|
||||
const VALID_PROJECT_NAME_REGEX = /^[a-zA-Z0-9 \-_.()\u00C0-\u024F]+$/;
|
||||
|
||||
/**
|
||||
* Characters that are NOT allowed in project names — replaced during sanitization.
|
||||
*/
|
||||
const DISALLOWED_CHARS_REGEX = /[^a-zA-Z0-9 \-_.()\u00C0-\u024F]/g;
|
||||
|
||||
/**
|
||||
* Validate whether a project name contains only allowed characters.
|
||||
* Does NOT check for empty string — caller should check that separately.
|
||||
*/
|
||||
export function isValidProjectName(name: string): boolean {
|
||||
if (!name || name.trim().length === 0) return false;
|
||||
if (name.startsWith('.')) return false; // hidden files on Unix
|
||||
return VALID_PROJECT_NAME_REGEX.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a project name by replacing disallowed characters with underscores,
|
||||
* collapsing consecutive underscores/spaces, and trimming.
|
||||
*/
|
||||
export function sanitizeProjectName(name: string): string {
|
||||
let sanitized = name.replace(DISALLOWED_CHARS_REGEX, '_');
|
||||
|
||||
// Collapse consecutive underscores
|
||||
sanitized = sanitized.replace(/_{2,}/g, '_');
|
||||
|
||||
// Collapse consecutive spaces
|
||||
sanitized = sanitized.replace(/ {2,}/g, ' ');
|
||||
|
||||
// Trim leading/trailing whitespace, underscores, and dots
|
||||
sanitized = sanitized.replace(/^[.\s_]+|[.\s_]+$/g, '').trim();
|
||||
|
||||
// If everything was stripped, provide a fallback
|
||||
if (sanitized.length === 0) {
|
||||
sanitized = 'Untitled Project';
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
+10
-17
@@ -1,5 +1,4 @@
|
||||
import { KGStorage, DuplicateEntryError } from '../core/io/KGStorage';
|
||||
import { DB_CONSTANTS } from '../constants/coreConstants';
|
||||
import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStorage';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
|
||||
/**
|
||||
@@ -13,43 +12,37 @@ export const saveProject = async (
|
||||
projectName: string,
|
||||
setStatus: (status: string) => void
|
||||
): Promise<boolean> => {
|
||||
const storage = KGStorage.getInstance();
|
||||
|
||||
const storage = KGProjectStorage.getInstance();
|
||||
|
||||
try {
|
||||
await storage.save(
|
||||
DB_CONSTANTS.DB_NAME,
|
||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
||||
projectName,
|
||||
KGCore.instance().getCurrentProject(),
|
||||
false,
|
||||
DB_CONSTANTS.DB_VERSION
|
||||
);
|
||||
|
||||
|
||||
setStatus(`Project "${projectName}" has been saved`);
|
||||
console.log("project saved successfully");
|
||||
return true;
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error saving project:", error);
|
||||
|
||||
|
||||
if (error instanceof DuplicateEntryError) {
|
||||
const confirmed = window.confirm(`Project "${projectName}" already exists. Do you want to overwrite it?`);
|
||||
|
||||
|
||||
if (confirmed) {
|
||||
try {
|
||||
await storage.save(
|
||||
DB_CONSTANTS.DB_NAME,
|
||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
||||
projectName,
|
||||
KGCore.instance().getCurrentProject(),
|
||||
true,
|
||||
DB_CONSTANTS.DB_VERSION
|
||||
);
|
||||
|
||||
|
||||
setStatus(`Project "${projectName}" has been saved`);
|
||||
console.log("project saved successfully after overwrite");
|
||||
return true;
|
||||
|
||||
|
||||
} catch (overwriteError) {
|
||||
console.error("Error overwriting project:", overwriteError);
|
||||
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
|
||||
@@ -65,4 +58,4 @@ export const saveProject = async (
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user