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
|
- [ ] Support WAV audio tracks
|
||||||
- [ ] Filters and effects
|
- [ ] Filters and effects
|
||||||
- [ ] MCP Support
|
- [ ] 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
|
- [ ] Automatically compact conversations when the context window runs low on space
|
||||||
|
|
||||||
## Help Needed
|
## Help Needed
|
||||||
|
|||||||
+2
-1
@@ -21,7 +21,8 @@ export default tseslint.config([
|
|||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-unused-vars': 'warn', // Downgrade from error to warning
|
'@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",
|
"name": "K.G.Studio",
|
||||||
"version": "0.8.0-build.20260123",
|
"version": "0.9.0-build.20260406",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "K.G.Studio",
|
"name": "K.G.Studio",
|
||||||
"version": "0.8.0-build.20260123",
|
"version": "0.9.0-build.20260406",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"openai": "^6.33.0",
|
"openai": "^6.33.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -3805,7 +3806,6 @@
|
|||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cosmiconfig": {
|
"node_modules/cosmiconfig": {
|
||||||
@@ -5387,6 +5387,12 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -5441,7 +5447,6 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ini": {
|
"node_modules/ini": {
|
||||||
@@ -5656,7 +5661,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
@@ -5931,6 +5935,18 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -5965,6 +5981,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -11030,6 +11055,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -11377,7 +11408,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/property-information": {
|
"node_modules/property-information": {
|
||||||
@@ -11792,7 +11822,6 @@
|
|||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
@@ -12156,7 +12185,6 @@
|
|||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
@@ -12529,6 +12557,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -12836,7 +12870,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
@@ -13674,7 +13707,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/validate-npm-package-license": {
|
"node_modules/validate-npm-package-license": {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"openai": "^6.33.0",
|
"openai": "^6.33.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^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 { useEffect } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import './styles/shared.css';
|
||||||
import { useProjectStore } from './stores/projectStore';
|
import { useProjectStore } from './stores/projectStore';
|
||||||
import { useGlobalKeyboardHandler } from './hooks/useGlobalKeyboardHandler';
|
import { useGlobalKeyboardHandler } from './hooks/useGlobalKeyboardHandler';
|
||||||
import Toolbar from './components/Toolbar';
|
import Toolbar from './components/Toolbar';
|
||||||
import StatusBar from './components/StatusBar';
|
import StatusBar from './components/StatusBar';
|
||||||
import TrackControl from './components/TrackControl';
|
|
||||||
import MainContent from './components/MainContent';
|
import MainContent from './components/MainContent';
|
||||||
import InstrumentSelection from './components/InstrumentSelection';
|
import InstrumentSelection from './components/InstrumentSelection';
|
||||||
import ChatBox from './components/ChatBox';
|
import ChatBox from './components/ChatBox';
|
||||||
@@ -135,14 +135,15 @@ function App() {
|
|||||||
<ChatBox isVisible={showChatBox && !showSettings} />
|
<ChatBox isVisible={showChatBox && !showSettings} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track Control */}
|
|
||||||
<TrackControl />
|
|
||||||
|
|
||||||
{/* Status Bar */}
|
{/* Status Bar */}
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
|
||||||
{/* Global Loading Overlay for instrument buffer loading */}
|
{/* Global Loading Overlay for instrument buffer loading */}
|
||||||
<GlobalLoadingOverlayContainer />
|
<GlobalLoadingOverlayContainer />
|
||||||
|
|
||||||
|
{/* Migration Loading Overlay */}
|
||||||
|
<MigrationOverlayContainer />
|
||||||
</div>
|
</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 React, { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||||
|
import './ChatBox.css';
|
||||||
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
|
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
|
||||||
import { UserMessage, AssistantMessage } from './chat';
|
import { UserMessage, AssistantMessage } from './chat';
|
||||||
import { AgentCore } from '../agent/core/AgentCore';
|
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 React, { useMemo, useState, useEffect } from 'react';
|
||||||
|
import './InstrumentSelection.css';
|
||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
|
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
|
||||||
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
|
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 React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import './MainContent.css';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
@@ -18,7 +19,7 @@ interface MainContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MainContent: React.FC<MainContentProps> = ({
|
const MainContent: React.FC<MainContentProps> = ({
|
||||||
onTrackClick = () => {} // Default to empty function if not provided
|
onTrackClick = () => { } // Default to empty function if not provided
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -33,18 +34,19 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
showPianoRoll,
|
showPianoRoll,
|
||||||
activeRegionId,
|
activeRegionId,
|
||||||
setShowPianoRoll,
|
setShowPianoRoll,
|
||||||
setActiveRegionId
|
setActiveRegionId,
|
||||||
|
addTrack
|
||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
|
|
||||||
// State to store regions
|
// State to store regions
|
||||||
const [regions, setRegions] = useState<RegionUI[]>([]);
|
const [regions, setRegions] = useState<RegionUI[]>([]);
|
||||||
|
|
||||||
// Drag state for track grid highlighting
|
// Drag state for track grid highlighting
|
||||||
const [draggedTrackIndex, setDraggedTrackIndex] = useState<number | null>(null);
|
const [draggedTrackIndex, setDraggedTrackIndex] = useState<number | null>(null);
|
||||||
const [dragOverTrackIndex, setDragOverTrackIndex] = useState<number | null>(null);
|
const [dragOverTrackIndex, setDragOverTrackIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// Piano roll state is now managed by the store - removed local state
|
// Piano roll state is now managed by the store - removed local state
|
||||||
|
|
||||||
// Region selection state
|
// Region selection state
|
||||||
const [selectedRegionId, setSelectedRegionId] = useState<string | null>(null);
|
const [selectedRegionId, setSelectedRegionId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -64,16 +66,16 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
// Register the delete function with the global manager
|
// Register the delete function with the global manager
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
regionDeleteManager.registerDeleteCallback(deleteSelectedRegions);
|
regionDeleteManager.registerDeleteCallback(deleteSelectedRegions);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
regionDeleteManager.unregisterDeleteCallback();
|
regionDeleteManager.unregisterDeleteCallback();
|
||||||
};
|
};
|
||||||
}, [deleteSelectedRegions]);
|
}, [deleteSelectedRegions]);
|
||||||
|
|
||||||
// Refs to track pending updates for verification
|
// Refs to track pending updates for verification
|
||||||
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
const pendingUpdates = useRef<Map<string, { trackId: string, regionId: string, startBeat: number, length: number }>>(new Map());
|
||||||
|
|
||||||
// Refs for bar numbers and loop range drag functionality
|
// Refs for bar numbers and loop range drag functionality
|
||||||
const barNumbersRef = useRef<HTMLDivElement | null>(null);
|
const barNumbersRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isLoopDraggingRef = useRef(false);
|
const isLoopDraggingRef = useRef(false);
|
||||||
@@ -87,26 +89,26 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
if (pendingUpdates.current.size > 0) {
|
if (pendingUpdates.current.size > 0) {
|
||||||
// Create a copy of the pending updates
|
// Create a copy of the pending updates
|
||||||
const updates = new Map(pendingUpdates.current);
|
const updates = new Map(pendingUpdates.current);
|
||||||
|
|
||||||
// Clear pending updates
|
// Clear pending updates
|
||||||
pendingUpdates.current.clear();
|
pendingUpdates.current.clear();
|
||||||
|
|
||||||
// Check each update
|
// Check each update
|
||||||
updates.forEach((update, key) => {
|
updates.forEach((update, key) => {
|
||||||
const { trackId, regionId, startBeat, length } = update;
|
const { trackId, regionId, startBeat, length } = update;
|
||||||
|
|
||||||
// Find the track
|
// Find the track
|
||||||
const track = tracks.find(t => t.getId().toString() === trackId);
|
const track = tracks.find(t => t.getId().toString() === trackId);
|
||||||
if (track) {
|
if (track) {
|
||||||
// Find the region
|
// Find the region
|
||||||
const regions = track.getRegions();
|
const regions = track.getRegions();
|
||||||
const region = regions.find(r => r.getId() === regionId);
|
const region = regions.find(r => r.getId() === regionId);
|
||||||
|
|
||||||
if (region && DEBUG_MODE.MAIN_CONTENT) {
|
if (region && DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Verification - Region ${regionId} in track ${trackId}:`);
|
console.log(`Verification - Region ${regionId} in track ${trackId}:`);
|
||||||
console.log(` Expected: startBeat=${startBeat}, length=${length}`);
|
console.log(` Expected: startBeat=${startBeat}, length=${length}`);
|
||||||
console.log(` Actual: startBeat=${region.getStartFromBeat()}, length=${region.getLength()}, trackId=${region.getTrackId()}, trackIndex=${region.getTrackIndex()}`);
|
console.log(` Actual: startBeat=${region.getStartFromBeat()}, length=${region.getLength()}, trackId=${region.getTrackId()}, trackIndex=${region.getTrackIndex()}`);
|
||||||
|
|
||||||
// Check if the update was successful
|
// Check if the update was successful
|
||||||
const success = region.getStartFromBeat() === startBeat && region.getLength() === length && region.getTrackId() === trackId;
|
const success = region.getStartFromBeat() === startBeat && region.getLength() === length && region.getTrackId() === trackId;
|
||||||
console.log(` Update successful: ${success}`);
|
console.log(` Update successful: ${success}`);
|
||||||
@@ -120,12 +122,12 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Create a new array of RegionUI objects based on the current tracks
|
// Create a new array of RegionUI objects based on the current tracks
|
||||||
const updatedRegions: RegionUI[] = [];
|
const updatedRegions: RegionUI[] = [];
|
||||||
|
|
||||||
// Iterate through all tracks
|
// Iterate through all tracks
|
||||||
tracks.forEach(track => {
|
tracks.forEach(track => {
|
||||||
const trackId = track.getId().toString();
|
const trackId = track.getId().toString();
|
||||||
const trackIndex = track.getTrackIndex();
|
const trackIndex = track.getTrackIndex();
|
||||||
|
|
||||||
// Iterate through all regions in the track
|
// Iterate through all regions in the track
|
||||||
track.getRegions().forEach(region => {
|
track.getRegions().forEach(region => {
|
||||||
if (region instanceof KGMidiRegion) {
|
if (region instanceof KGMidiRegion) {
|
||||||
@@ -133,7 +135,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1;
|
const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1;
|
||||||
const length = region.getLength() / beatsPerBar;
|
const length = region.getLength() / beatsPerBar;
|
||||||
|
|
||||||
// Create a RegionUI object
|
// Create a RegionUI object
|
||||||
updatedRegions.push({
|
updatedRegions.push({
|
||||||
id: region.getId(),
|
id: region.getId(),
|
||||||
@@ -146,7 +148,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the regions state
|
// Update the regions state
|
||||||
setRegions(updatedRegions);
|
setRegions(updatedRegions);
|
||||||
}, [tracks, timeSignature]);
|
}, [tracks, timeSignature]);
|
||||||
@@ -161,7 +163,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
const handleTracksReordered = (fromIndex: number, toIndex: number) => {
|
const handleTracksReordered = (fromIndex: number, toIndex: number) => {
|
||||||
// Reorder tracks in the store - this will also update trackIndex in each KGTrack
|
// Reorder tracks in the store - this will also update trackIndex in each KGTrack
|
||||||
reorderTracks(fromIndex, toIndex);
|
reorderTracks(fromIndex, toIndex);
|
||||||
|
|
||||||
// Update regions to match the new track order
|
// Update regions to match the new track order
|
||||||
setRegions(prevRegions => {
|
setRegions(prevRegions => {
|
||||||
return prevRegions.map(region => {
|
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
|
// If the region belongs to a track that was shifted due to the drag operation
|
||||||
else if (
|
else if (
|
||||||
(fromIndex < toIndex &&
|
(fromIndex < toIndex &&
|
||||||
region.trackIndex > fromIndex &&
|
region.trackIndex > fromIndex &&
|
||||||
region.trackIndex <= toIndex)
|
region.trackIndex <= toIndex)
|
||||||
) {
|
) {
|
||||||
// Shift up by 1
|
// Shift up by 1
|
||||||
return { ...region, trackIndex: region.trackIndex - 1 };
|
return { ...region, trackIndex: region.trackIndex - 1 };
|
||||||
}
|
}
|
||||||
else if (
|
else if (
|
||||||
(fromIndex > toIndex &&
|
(fromIndex > toIndex &&
|
||||||
region.trackIndex < fromIndex &&
|
region.trackIndex < fromIndex &&
|
||||||
region.trackIndex >= toIndex)
|
region.trackIndex >= toIndex)
|
||||||
) {
|
) {
|
||||||
// Shift down by 1
|
// Shift down by 1
|
||||||
return { ...region, trackIndex: region.trackIndex + 1 };
|
return { ...region, trackIndex: region.trackIndex + 1 };
|
||||||
@@ -190,7 +192,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
return region;
|
return region;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the grid drag state to match
|
// Update the grid drag state to match
|
||||||
setDraggedTrackIndex(null);
|
setDraggedTrackIndex(null);
|
||||||
setDragOverTrackIndex(null);
|
setDragOverTrackIndex(null);
|
||||||
@@ -200,53 +202,53 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
const handleRegionCreated = (trackIndex: number, regionUI: RegionUI, midiRegion: KGMidiRegion) => {
|
const handleRegionCreated = (trackIndex: number, regionUI: RegionUI, midiRegion: KGMidiRegion) => {
|
||||||
// Note: The region model is already created by the CreateRegionCommand
|
// Note: The region model is already created by the CreateRegionCommand
|
||||||
// We just need to update the UI state and handle selection
|
// We just need to update the UI state and handle selection
|
||||||
|
|
||||||
// Get the track for store updates
|
// Get the track for store updates
|
||||||
const track = tracks[trackIndex];
|
const track = tracks[trackIndex];
|
||||||
|
|
||||||
// Update the track in the store to reflect the command changes
|
// Update the track in the store to reflect the command changes
|
||||||
updateTrack(track);
|
updateTrack(track);
|
||||||
|
|
||||||
// Select the track that contains the new region
|
// Select the track that contains the new region
|
||||||
setSelectedTrack(track.getId().toString());
|
setSelectedTrack(track.getId().toString());
|
||||||
|
|
||||||
// Add the new region to the UI state and select it immediately
|
// Add the new region to the UI state and select it immediately
|
||||||
setRegions(prevRegions => {
|
setRegions(prevRegions => {
|
||||||
const updatedRegions = [...prevRegions, regionUI];
|
const updatedRegions = [...prevRegions, regionUI];
|
||||||
|
|
||||||
// Select the region using the updated regions array
|
// Select the region using the updated regions array
|
||||||
selectRegion(regionUI.id, updatedRegions);
|
selectRegion(regionUI.id, updatedRegions);
|
||||||
|
|
||||||
// Manually trigger selection sync to ensure UI updates immediately
|
// Manually trigger selection sync to ensure UI updates immediately
|
||||||
const { syncSelectionFromCore } = useProjectStore.getState();
|
const { syncSelectionFromCore } = useProjectStore.getState();
|
||||||
syncSelectionFromCore();
|
syncSelectionFromCore();
|
||||||
|
|
||||||
// If piano roll is visible, set this region as the active region
|
// If piano roll is visible, set this region as the active region
|
||||||
if (showPianoRoll) {
|
if (showPianoRoll) {
|
||||||
setActiveRegionId(regionUI.id);
|
setActiveRegionId(regionUI.id);
|
||||||
|
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Newly created region ${regionUI.id} set as active region in piano roll`);
|
console.log(`Newly created region ${regionUI.id} set as active region in piano roll`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedRegions;
|
return updatedRegions;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle region updates (resize, move, etc.)
|
// Handle region updates (resize, move, etc.)
|
||||||
const handleRegionUpdated = (
|
const handleRegionUpdated = (
|
||||||
regionId: string,
|
regionId: string,
|
||||||
updates: Partial<RegionUI>,
|
updates: Partial<RegionUI>,
|
||||||
expectedModelUpdates?: { startBeat: number, length: number }
|
expectedModelUpdates?: { startBeat: number, length: number }
|
||||||
) => {
|
) => {
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Updating region ${regionId} with:`, updates);
|
console.log(`Updating region ${regionId} with:`, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the region when it's being updated (resize or move)
|
// Select the region when it's being updated (resize or move)
|
||||||
selectRegion(regionId);
|
selectRegion(regionId);
|
||||||
|
|
||||||
// Find the region to determine which track to select
|
// Find the region to determine which track to select
|
||||||
const updatedRegion = regions.find(r => r.id === regionId);
|
const updatedRegion = regions.find(r => r.id === regionId);
|
||||||
if (updatedRegion) {
|
if (updatedRegion) {
|
||||||
@@ -257,7 +259,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
setSelectedTrack(track.getId().toString());
|
setSelectedTrack(track.getId().toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the region in the UI state
|
// Update the region in the UI state
|
||||||
setRegions(prevRegions => {
|
setRegions(prevRegions => {
|
||||||
return prevRegions.map(region => {
|
return prevRegions.map(region => {
|
||||||
@@ -267,31 +269,31 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
return region;
|
return region;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find the region that was updated
|
// Find the region that was updated
|
||||||
const region = regions.find(r => r.id === regionId);
|
const region = regions.find(r => r.id === regionId);
|
||||||
if (!region) return;
|
if (!region) return;
|
||||||
|
|
||||||
// Check if the track ID is being updated (region moved to different track)
|
// Check if the track ID is being updated (region moved to different track)
|
||||||
if (updates.trackId && updates.trackId !== region.trackId) {
|
if (updates.trackId && updates.trackId !== region.trackId) {
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Region ${regionId} moved from track ${region.trackId} to track ${updates.trackId}`);
|
console.log(`Region ${regionId} moved from track ${region.trackId} to track ${updates.trackId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the original track
|
// Get the original track
|
||||||
const originalTrack = tracks.find(t => t.getId().toString() === region.trackId);
|
const originalTrack = tracks.find(t => t.getId().toString() === region.trackId);
|
||||||
|
|
||||||
// Get the target track
|
// Get the target track
|
||||||
const targetTrack = tracks.find(t => t.getId().toString() === updates.trackId);
|
const targetTrack = tracks.find(t => t.getId().toString() === updates.trackId);
|
||||||
|
|
||||||
if (originalTrack && targetTrack) {
|
if (originalTrack && targetTrack) {
|
||||||
// Select the target track that now contains the region
|
// Select the target track that now contains the region
|
||||||
setSelectedTrack(targetTrack.getId().toString());
|
setSelectedTrack(targetTrack.getId().toString());
|
||||||
|
|
||||||
// Update both tracks in the store
|
// Update both tracks in the store
|
||||||
updateTrack(originalTrack);
|
updateTrack(originalTrack);
|
||||||
updateTrack(targetTrack);
|
updateTrack(targetTrack);
|
||||||
|
|
||||||
// Add to pending updates for verification
|
// Add to pending updates for verification
|
||||||
if (expectedModelUpdates) {
|
if (expectedModelUpdates) {
|
||||||
const key = `${updates.trackId}-${regionId}-${Date.now()}`;
|
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
|
// Log the track's regions before updating the store
|
||||||
const trackRegions = track.getRegions();
|
const trackRegions = track.getRegions();
|
||||||
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
|
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
|
||||||
|
|
||||||
if (midiRegion) {
|
if (midiRegion) {
|
||||||
// If we have expected model updates, use those
|
// If we have expected model updates, use those
|
||||||
if (expectedModelUpdates) {
|
if (expectedModelUpdates) {
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`MainContent - Expected model updates: startBeat=${expectedModelUpdates.startBeat}, length=${expectedModelUpdates.length}`);
|
console.log(`MainContent - Expected model updates: startBeat=${expectedModelUpdates.startBeat}, length=${expectedModelUpdates.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to pending updates for verification
|
// Add to pending updates for verification
|
||||||
const key = `${track.getId()}-${regionId}-${Date.now()}`;
|
const key = `${track.getId()}-${regionId}-${Date.now()}`;
|
||||||
pendingUpdates.current.set(key, {
|
pendingUpdates.current.set(key, {
|
||||||
@@ -330,11 +332,11 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
// Otherwise use the current values (for backward compatibility)
|
// Otherwise use the current values (for backward compatibility)
|
||||||
const startBeat = midiRegion.getStartFromBeat();
|
const startBeat = midiRegion.getStartFromBeat();
|
||||||
const length = midiRegion.getLength();
|
const length = midiRegion.getLength();
|
||||||
|
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`MainContent - Region before store update: startBeat=${startBeat}, length=${length}`);
|
console.log(`MainContent - Region before store update: startBeat=${startBeat}, length=${length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to pending updates for verification
|
// Add to pending updates for verification
|
||||||
const key = `${track.getId()}-${regionId}-${Date.now()}`;
|
const key = `${track.getId()}-${regionId}-${Date.now()}`;
|
||||||
pendingUpdates.current.set(key, {
|
pendingUpdates.current.set(key, {
|
||||||
@@ -345,16 +347,16 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the track in the store to persist changes
|
// Update the track in the store to persist changes
|
||||||
updateTrack(track);
|
updateTrack(track);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If piano roll is visible, set this region as the active region
|
// If piano roll is visible, set this region as the active region
|
||||||
if (showPianoRoll) {
|
if (showPianoRoll) {
|
||||||
setActiveRegionId(regionId);
|
setActiveRegionId(regionId);
|
||||||
|
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Updated region ${regionId} set as active region in piano roll`);
|
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[]) => {
|
const selectRegion = (regionId: string, regionsToSearch?: RegionUI[]) => {
|
||||||
// Clear any existing selections using store method
|
// Clear any existing selections using store method
|
||||||
clearAllSelections();
|
clearAllSelections();
|
||||||
|
|
||||||
// Find the region in the UI state (use provided regions or current state)
|
// Find the region in the UI state (use provided regions or current state)
|
||||||
const regionsToUse = regionsToSearch || regions;
|
const regionsToUse = regionsToSearch || regions;
|
||||||
const region = regionsToUse.find(r => r.id === regionId);
|
const region = regionsToUse.find(r => r.id === regionId);
|
||||||
@@ -375,7 +377,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the track that contains this region
|
// Find the track that contains this region
|
||||||
const track = tracks.find(t => t.getId().toString() === region.trackId);
|
const track = tracks.find(t => t.getId().toString() === region.trackId);
|
||||||
if (!track) {
|
if (!track) {
|
||||||
@@ -384,28 +386,28 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the region in the track's model
|
// Find the region in the track's model
|
||||||
const trackRegions = track.getRegions();
|
const trackRegions = track.getRegions();
|
||||||
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
|
const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined;
|
||||||
|
|
||||||
if (!midiRegion) {
|
if (!midiRegion) {
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`MIDI region not found in track model: ${regionId}`);
|
console.log(`MIDI region not found in track model: ${regionId}`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the region to KGCore's selection
|
// Add the region to KGCore's selection
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
core.addSelectedItem(midiRegion);
|
core.addSelectedItem(midiRegion);
|
||||||
|
|
||||||
// Update the region's internal selection state
|
// Update the region's internal selection state
|
||||||
midiRegion.select();
|
midiRegion.select();
|
||||||
|
|
||||||
// Set the selected region (this might be redundant now, but keeping for compatibility)
|
// Set the selected region (this might be redundant now, but keeping for compatibility)
|
||||||
setSelectedRegionId(regionId);
|
setSelectedRegionId(regionId);
|
||||||
|
|
||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Selected region: ${regionId} (added to KGCore selection)`);
|
console.log(`Selected region: ${regionId} (added to KGCore selection)`);
|
||||||
}
|
}
|
||||||
@@ -416,10 +418,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Region clicked in MainContent (selection only): ${regionId}`);
|
console.log(`Region clicked in MainContent (selection only): ${regionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the region
|
// Select the region
|
||||||
selectRegion(regionId);
|
selectRegion(regionId);
|
||||||
|
|
||||||
// Also select the containing track
|
// Also select the containing track
|
||||||
const region = regions.find(r => r.id === regionId);
|
const region = regions.find(r => r.id === regionId);
|
||||||
if (!region) return;
|
if (!region) return;
|
||||||
@@ -433,10 +435,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
if (DEBUG_MODE.MAIN_CONTENT) {
|
if (DEBUG_MODE.MAIN_CONTENT) {
|
||||||
console.log(`Open piano roll via pencil for region: ${regionId}`);
|
console.log(`Open piano roll via pencil for region: ${regionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse selection logic
|
// Reuse selection logic
|
||||||
handleRegionClick(regionId);
|
handleRegionClick(regionId);
|
||||||
|
|
||||||
// Activate and show piano roll
|
// Activate and show piano roll
|
||||||
setActiveRegionId(regionId);
|
setActiveRegionId(regionId);
|
||||||
setShowPianoRoll(true);
|
setShowPianoRoll(true);
|
||||||
@@ -458,8 +460,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
// Skip if user is typing in an input field (including ChatBox)
|
// Skip if user is typing in an input field (including ChatBox)
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (target && (
|
if (target && (
|
||||||
target.tagName === 'INPUT' ||
|
target.tagName === 'INPUT' ||
|
||||||
target.tagName === 'TEXTAREA' ||
|
target.tagName === 'TEXTAREA' ||
|
||||||
target.contentEditable === 'true' ||
|
target.contentEditable === 'true' ||
|
||||||
target.hasAttribute('data-chatbox-input') ||
|
target.hasAttribute('data-chatbox-input') ||
|
||||||
target.closest('.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)
|
// 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 isInPianoRoll = document.querySelector('.piano-roll')?.contains(event.target as Node);
|
||||||
const isPianoRollOpen = showPianoRoll;
|
const isPianoRollOpen = showPianoRoll;
|
||||||
|
|
||||||
if (!isInPianoRoll && !isPianoRollOpen) {
|
if (!isInPianoRoll && !isPianoRollOpen) {
|
||||||
const deleted = deleteSelectedRegions();
|
const deleted = deleteSelectedRegions();
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
@@ -482,10 +484,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add event listener
|
// Add event listener
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
// Remove event listener on cleanup
|
// Remove event listener on cleanup
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
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)
|
// Utility function to calculate playhead position from mouse coordinates (bar-level snapping)
|
||||||
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => {
|
||||||
if (!barNumbersRef.current) return null;
|
if (!barNumbersRef.current) return null;
|
||||||
|
|
||||||
const rect = barNumbersRef.current.getBoundingClientRect();
|
const rect = barNumbersRef.current.getBoundingClientRect();
|
||||||
const relativeX = clientX - rect.left;
|
const relativeX = clientX - rect.left;
|
||||||
|
|
||||||
// Calculate the width of each bar
|
// Calculate the width of each bar
|
||||||
const barWidth = parseInt(
|
const barWidth = parseInt(
|
||||||
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
|
||||||
) || 40;
|
) || 40;
|
||||||
|
|
||||||
// Find the closest bar start (using Math.round for nearest bar)
|
// Find the closest bar start (using Math.round for nearest bar)
|
||||||
const barIndex = Math.round(relativeX / barWidth);
|
const barIndex = Math.round(relativeX / barWidth);
|
||||||
|
|
||||||
// Ensure we don't go below 0
|
// Ensure we don't go below 0
|
||||||
const clampedBarIndex = Math.max(0, barIndex);
|
const clampedBarIndex = Math.max(0, barIndex);
|
||||||
|
|
||||||
// Calculate destination beat position (start of the bar)
|
// Calculate destination beat position (start of the bar)
|
||||||
const beatsPerBar = timeSignature.numerator;
|
const beatsPerBar = timeSignature.numerator;
|
||||||
const destinationBeatPosition = clampedBarIndex * beatsPerBar;
|
const destinationBeatPosition = clampedBarIndex * beatsPerBar;
|
||||||
|
|
||||||
return destinationBeatPosition;
|
return destinationBeatPosition;
|
||||||
}, [timeSignature]);
|
}, [timeSignature]);
|
||||||
|
|
||||||
@@ -688,7 +690,9 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
|
<div className={`main-content${showInstrumentSelection ? ' has-left-instrument' : ''}`}>
|
||||||
<div className="main-content-wrapper">
|
<div className="main-content-wrapper">
|
||||||
{/* Top-left spacer */}
|
{/* 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 */}
|
{/* Bar numbers at the top */}
|
||||||
<div
|
<div
|
||||||
@@ -705,7 +709,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="main-content-body">
|
<div className="main-content-body">
|
||||||
{/* Fixed left panel with track info */}
|
{/* Fixed left panel with track info */}
|
||||||
<TrackInfoPanel
|
<TrackInfoPanel
|
||||||
@@ -714,7 +718,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
onTrackNameEdit={handleTrackNameEdit}
|
onTrackNameEdit={handleTrackNameEdit}
|
||||||
onTracksReordered={handleTracksReordered}
|
onTracksReordered={handleTracksReordered}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrollable grid area */}
|
{/* Scrollable grid area */}
|
||||||
<TrackGridPanel
|
<TrackGridPanel
|
||||||
tracks={tracks}
|
tracks={tracks}
|
||||||
@@ -731,10 +735,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Piano Roll - render using portal */}
|
{/* Piano Roll - render using portal */}
|
||||||
{showPianoRoll && createPortal(
|
{showPianoRoll && createPortal(
|
||||||
<PianoRoll
|
<PianoRoll
|
||||||
onClose={handlePianoRollClose}
|
onClose={handlePianoRollClose}
|
||||||
regionId={activeRegionId}
|
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 React from 'react';
|
||||||
|
import './StatusBar.css';
|
||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
|
|
||||||
const StatusBar: React.FC = () => {
|
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 React from 'react';
|
||||||
|
import './Toolbar.css';
|
||||||
import { saveProject } from '../util/saveUtil';
|
import { saveProject } from '../util/saveUtil';
|
||||||
import { KGStorage } from '../core/io/KGStorage';
|
import { KGProjectStorage } from '../core/io/KGProjectStorage';
|
||||||
import { DB_CONSTANTS } from '../constants/coreConstants';
|
import { isValidProjectName } from '../util/projectNameUtil';
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { DEBUG_MODE } from '../constants/uiConstants';
|
import { DEBUG_MODE } from '../constants/uiConstants';
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
FaCog
|
FaCog
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
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 { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6';
|
||||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
import { regionDeleteManager } from '../util/regionDeleteUtil';
|
||||||
@@ -58,11 +59,17 @@ const Toolbar: React.FC = () => {
|
|||||||
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
|
const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
|
||||||
|
|
||||||
// Export options
|
// 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 handleProjectNameClick = () => {
|
||||||
const newName = prompt("Enter project name:", projectName);
|
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
|
// Common project loading logic extracted for reuse
|
||||||
@@ -144,14 +151,8 @@ const Toolbar: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to load the project from storage
|
// Try to load the project from storage
|
||||||
const storage = KGStorage.getInstance();
|
const storage = KGProjectStorage.getInstance();
|
||||||
const loadedProject = await storage.load(
|
const loadedProject = await storage.load(projectNameToLoad.trim());
|
||||||
DB_CONSTANTS.DB_NAME,
|
|
||||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
|
||||||
projectNameToLoad.trim(),
|
|
||||||
KGProject,
|
|
||||||
DB_CONSTANTS.DB_VERSION
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!loadedProject) {
|
if (!loadedProject) {
|
||||||
window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`);
|
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);
|
console.log("user selected export option:", exportType);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exportType === "Export to KGStudio JSON file") {
|
if (exportType === "Export to KGStudio file") {
|
||||||
handleExportKGStudioJSON();
|
handleExportKGStudio();
|
||||||
} else if (exportType === "Export to MIDI file") {
|
} else if (exportType === "Export to MIDI file") {
|
||||||
handleExportMIDI();
|
handleExportMIDI();
|
||||||
}
|
}
|
||||||
@@ -190,46 +191,39 @@ const Toolbar: React.FC = () => {
|
|||||||
setShowExportDropdown(false);
|
setShowExportDropdown(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportKGStudioJSON = () => {
|
const handleExportKGStudio = async () => {
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log("exporting to KGStudio JSON file");
|
console.log("exporting to KGStudio file");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the current project from KGCore
|
// First save the current project to OPFS so the export reflects the latest state
|
||||||
const currentProject = KGCore.instance().getCurrentProject();
|
const storage = KGProjectStorage.getInstance();
|
||||||
|
await storage.save(projectName, KGCore.instance().getCurrentProject(), true);
|
||||||
// Serialize the project to JSON (same format as saved to IndexedDB)
|
|
||||||
// Use instanceToPlain to include type information for class-transformer
|
// Bundle the project folder into a zip
|
||||||
const projectData = JSON.stringify(instanceToPlain(currentProject), null, 2);
|
const blob = await storage.exportAsZip(projectName);
|
||||||
|
|
||||||
// Create a downloadable blob
|
// Trigger download
|
||||||
const blob = new Blob([projectData], { type: 'application/json' });
|
|
||||||
|
|
||||||
// Create a temporary download link
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = `${projectName}.json`;
|
link.download = `${projectName}.kgstudio`;
|
||||||
|
|
||||||
// Trigger download
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
setStatus(`Project "${projectName}" exported as JSON file`);
|
setStatus(`Project "${projectName}" exported as KGStudio file`);
|
||||||
|
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
if (DEBUG_MODE.TOOLBAR) {
|
||||||
console.log("KGStudio JSON export completed successfully");
|
console.log("KGStudio export completed successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error exporting KGStudio JSON:", error);
|
console.error("Error exporting KGStudio file:", error);
|
||||||
setStatus(`Error exporting project: ${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();
|
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fileExtension === '.json') {
|
if (fileExtension === '.kgstudio') {
|
||||||
// Handle KGStudio JSON import
|
// Handle KGStudio bundle import
|
||||||
|
await handleKGStudioFileImport(file);
|
||||||
|
} else if (fileExtension === '.json') {
|
||||||
|
// Handle legacy KGStudio JSON import
|
||||||
await handleKGStudioJSONImport(file);
|
await handleKGStudioJSONImport(file);
|
||||||
} else if (fileExtension === '.mid' || fileExtension === '.midi') {
|
} else if (fileExtension === '.mid' || fileExtension === '.midi') {
|
||||||
// Handle MIDI import
|
// 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) => {
|
const handleKGStudioJSONImport = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
// Read the file content
|
|
||||||
const fileContent = await file.text();
|
const fileContent = await file.text();
|
||||||
|
|
||||||
const projectData = JSON.parse(fileContent);
|
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);
|
const deserializedResult = plainToInstance(KGProject, projectData);
|
||||||
|
const project = Array.isArray(deserializedResult)
|
||||||
// Handle case where plainToInstance might return an array
|
? deserializedResult[0] || null
|
||||||
const deserializedProject = Array.isArray(deserializedResult)
|
|
||||||
? deserializedResult[0] || null
|
|
||||||
: deserializedResult;
|
: deserializedResult;
|
||||||
|
|
||||||
if (!deserializedProject) {
|
if (!project) {
|
||||||
throw new Error("Failed to deserialize project data");
|
throw new Error("Failed to deserialize project data");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the project using common loading logic
|
// Use the project's name for the OPFS folder, auto-rename if it already exists
|
||||||
await loadProjectFromData(deserializedProject, `File "${file.name}"`);
|
const storage = KGProjectStorage.getInstance();
|
||||||
|
const importedName = await storage.resolveUniqueName(project.getName() || 'Imported Project');
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
|
||||||
console.log("KGStudio JSON project imported successfully:", deserializedProject);
|
// 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) {
|
} catch (error) {
|
||||||
throw new Error(`Invalid KGStudio JSON file: ${error}`);
|
throw new Error(`Invalid KGStudio JSON file: ${error}`);
|
||||||
}
|
}
|
||||||
@@ -803,7 +832,7 @@ const Toolbar: React.FC = () => {
|
|||||||
isVisible={showImportModal}
|
isVisible={showImportModal}
|
||||||
onClose={() => setShowImportModal(false)}
|
onClose={() => setShowImportModal(false)}
|
||||||
onFileImport={handleFileImport}
|
onFileImport={handleFileImport}
|
||||||
acceptedTypes={['.json', '.mid', '.midi']}
|
acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
|
||||||
title="Import Project"
|
title="Import Project"
|
||||||
description="Drag and drop your project file here"
|
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 React, { useCallback, useState } from 'react';
|
||||||
|
import './FileImportModal.css';
|
||||||
import { FaTimes } from 'react-icons/fa';
|
import { FaTimes } from 'react-icons/fa';
|
||||||
|
|
||||||
interface FileImportModalProps {
|
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 React from 'react';
|
||||||
|
import './LoadingOverlay.css';
|
||||||
|
|
||||||
interface LoadingOverlayProps {
|
interface LoadingOverlayProps {
|
||||||
visible: boolean;
|
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 React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import './PianoRoll.css';
|
||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { FaGripLines } from 'react-icons/fa';
|
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 React, { useState } from 'react';
|
||||||
|
import './Settings.css';
|
||||||
import SettingsSidebar from './SettingsSidebar';
|
import SettingsSidebar from './SettingsSidebar';
|
||||||
import GeneralSettings from './sections/GeneralSettings';
|
import GeneralSettings from './sections/GeneralSettings';
|
||||||
import BehaviorSettings from './sections/BehaviorSettings';
|
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 React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import './Region.css';
|
||||||
import { FaPencilAlt } from 'react-icons/fa';
|
import { FaPencilAlt } from 'react-icons/fa';
|
||||||
import type { ResizeAction } from '../interfaces';
|
import type { ResizeAction } from '../interfaces';
|
||||||
import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants';
|
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 React, { useState } from 'react';
|
||||||
|
import './Track.css';
|
||||||
import { KGTrack } from '../../core/track/KGTrack';
|
import { KGTrack } from '../../core/track/KGTrack';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import TrackInfoItem from './TrackInfoItem';
|
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 = {
|
export const URL_CONSTANTS = {
|
||||||
DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
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 { KGProject } from './KGProject';
|
||||||
import { KGAudioInterface } from './audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from './audio-interface/KGAudioInterface';
|
||||||
import { ConfigManager } from './config/ConfigManager';
|
import { ConfigManager } from './config/ConfigManager';
|
||||||
|
import { KGProjectStorage } from './io/KGProjectStorage';
|
||||||
|
import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader';
|
||||||
import { KGMidiRegion } from './region/KGMidiRegion';
|
import { KGMidiRegion } from './region/KGMidiRegion';
|
||||||
import { KGMidiNote } from './midi/KGMidiNote';
|
import { KGMidiNote } from './midi/KGMidiNote';
|
||||||
import { KGRegion } from './region/KGRegion';
|
import { KGRegion } from './region/KGRegion';
|
||||||
@@ -30,6 +32,8 @@ export class KGCore {
|
|||||||
private copiedItems: Selectable[] = [];
|
private copiedItems: Selectable[] = [];
|
||||||
|
|
||||||
private isPlaying: boolean = false;
|
private isPlaying: boolean = false;
|
||||||
|
private isMigrating: boolean = false;
|
||||||
|
private migrationStateChangeCallback: ((isMigrating: boolean) => void) | null = null;
|
||||||
|
|
||||||
// Timer management for playback
|
// Timer management for playback
|
||||||
private playbackIntervalId: number | null = null;
|
private playbackIntervalId: number | null = null;
|
||||||
@@ -71,11 +75,23 @@ export class KGCore {
|
|||||||
// Initialize configuration manager
|
// Initialize configuration manager
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
await configManager.initialize();
|
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
|
// Initialize audio interface
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
await audioInterface.initialize();
|
await audioInterface.initialize();
|
||||||
|
|
||||||
console.log("KGCore components initialized successfully");
|
console.log("KGCore components initialized successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize KGCore:", error);
|
console.error("Failed to initialize KGCore:", error);
|
||||||
@@ -194,7 +210,23 @@ export class KGCore {
|
|||||||
this.selectionChangeCallbacks.forEach(callback => callback());
|
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 {
|
public getIsPlaying(): boolean {
|
||||||
return this.isPlaying;
|
return this.isPlaying;
|
||||||
}
|
}
|
||||||
|
|||||||
+215
-1
@@ -28,7 +28,8 @@ export class KGDebugger {
|
|||||||
'createTestRegion()',
|
'createTestRegion()',
|
||||||
'testExtractXMLFromString(input)',
|
'testExtractXMLFromString(input)',
|
||||||
'testToolCall(jsonInput)',
|
'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(" testExtractXMLFromString(input) - Test XML extraction from string");
|
||||||
console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results");
|
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(" 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(" help() - Show this help");
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log("💡 Usage tips:");
|
console.log("💡 Usage tips:");
|
||||||
@@ -446,4 +448,216 @@ export class KGDebugger {
|
|||||||
console.error('❌ Error in inputChatBox:', error);
|
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 { KGConfigStorage } from '../io/KGConfigStorage';
|
||||||
import { DB_CONSTANTS } from '../../constants/coreConstants';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application configuration interface
|
* Application configuration interface
|
||||||
@@ -99,7 +98,7 @@ export class ConfigManager {
|
|||||||
|
|
||||||
// Configuration state
|
// Configuration state
|
||||||
private config: AppConfig;
|
private config: AppConfig;
|
||||||
private storage: KGStorage;
|
private storage: KGConfigStorage;
|
||||||
private isInitialized: boolean = false;
|
private isInitialized: boolean = false;
|
||||||
private defaultConfig: AppConfig | null = null;
|
private defaultConfig: AppConfig | null = null;
|
||||||
private changeListeners: Set<(changedKeys: string[]) => void> = new Set();
|
private changeListeners: Set<(changedKeys: string[]) => void> = new Set();
|
||||||
@@ -108,7 +107,7 @@ export class ConfigManager {
|
|||||||
private constructor() {
|
private constructor() {
|
||||||
// Initialize with empty config, will be loaded during initialize()
|
// Initialize with empty config, will be loaded during initialize()
|
||||||
this.config = {} as AppConfig;
|
this.config = {} as AppConfig;
|
||||||
this.storage = KGStorage.getInstance();
|
this.storage = KGConfigStorage.getInstance();
|
||||||
console.log('ConfigManager initialized');
|
console.log('ConfigManager initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,11 +261,8 @@ export class ConfigManager {
|
|||||||
// For config, we don't use class-transformer since it's plain objects
|
// 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
|
// So we'll use a simple object approach and handle it directly with KGStorage
|
||||||
const savedConfigData = await this.storage.load(
|
const savedConfigData = await this.storage.load(
|
||||||
DB_CONSTANTS.DB_NAME,
|
|
||||||
DB_CONSTANTS.CONFIG_STORE_NAME,
|
|
||||||
ConfigManager.CONFIG_KEY,
|
ConfigManager.CONFIG_KEY,
|
||||||
Object, // Simple object class
|
Object, // Simple object class
|
||||||
DB_CONSTANTS.DB_VERSION
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (savedConfigData) {
|
if (savedConfigData) {
|
||||||
@@ -294,12 +290,9 @@ export class ConfigManager {
|
|||||||
: this.config;
|
: this.config;
|
||||||
|
|
||||||
await this.storage.save(
|
await this.storage.save(
|
||||||
DB_CONSTANTS.DB_NAME,
|
|
||||||
DB_CONSTANTS.CONFIG_STORE_NAME,
|
|
||||||
ConfigManager.CONFIG_KEY,
|
ConfigManager.CONFIG_KEY,
|
||||||
configToPersist,
|
configToPersist,
|
||||||
true, // Always overwrite config
|
true, // Always overwrite config
|
||||||
DB_CONSTANTS.DB_VERSION
|
|
||||||
);
|
);
|
||||||
console.log('Saved config to storage:', configToPersist);
|
console.log('Saved config to storage:', configToPersist);
|
||||||
} catch (error) {
|
} 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);
|
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"] {
|
input[type="range"] {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './styles/variables.css';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import { KGCore } from './core/KGCore';
|
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 { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStorage';
|
||||||
import { DB_CONSTANTS } from '../constants/coreConstants';
|
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,43 +12,37 @@ export const saveProject = async (
|
|||||||
projectName: string,
|
projectName: string,
|
||||||
setStatus: (status: string) => void
|
setStatus: (status: string) => void
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const storage = KGStorage.getInstance();
|
const storage = KGProjectStorage.getInstance();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.save(
|
await storage.save(
|
||||||
DB_CONSTANTS.DB_NAME,
|
|
||||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
|
||||||
projectName,
|
projectName,
|
||||||
KGCore.instance().getCurrentProject(),
|
KGCore.instance().getCurrentProject(),
|
||||||
false,
|
false,
|
||||||
DB_CONSTANTS.DB_VERSION
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setStatus(`Project "${projectName}" has been saved`);
|
setStatus(`Project "${projectName}" has been saved`);
|
||||||
console.log("project saved successfully");
|
console.log("project saved successfully");
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving project:", error);
|
console.error("Error saving project:", error);
|
||||||
|
|
||||||
if (error instanceof DuplicateEntryError) {
|
if (error instanceof DuplicateEntryError) {
|
||||||
const confirmed = window.confirm(`Project "${projectName}" already exists. Do you want to overwrite it?`);
|
const confirmed = window.confirm(`Project "${projectName}" already exists. Do you want to overwrite it?`);
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
try {
|
try {
|
||||||
await storage.save(
|
await storage.save(
|
||||||
DB_CONSTANTS.DB_NAME,
|
|
||||||
DB_CONSTANTS.PROJECTS_STORE_NAME,
|
|
||||||
projectName,
|
projectName,
|
||||||
KGCore.instance().getCurrentProject(),
|
KGCore.instance().getCurrentProject(),
|
||||||
true,
|
true,
|
||||||
DB_CONSTANTS.DB_VERSION
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setStatus(`Project "${projectName}" has been saved`);
|
setStatus(`Project "${projectName}" has been saved`);
|
||||||
console.log("project saved successfully after overwrite");
|
console.log("project saved successfully after overwrite");
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (overwriteError) {
|
} catch (overwriteError) {
|
||||||
console.error("Error overwriting project:", overwriteError);
|
console.error("Error overwriting project:", overwriteError);
|
||||||
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
|
window.alert(`An error occurred while overwriting the project: ${overwriteError}`);
|
||||||
@@ -65,4 +58,4 @@ export const saveProject = async (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user