├── .prettierignore ├── .prettierrc ├── .firebaserc ├── src ├── components │ ├── ui │ │ ├── input │ │ │ ├── index.js │ │ │ └── Input.vue │ │ ├── label │ │ │ ├── index.js │ │ │ └── Label.vue │ │ ├── slider │ │ │ ├── index.js │ │ │ └── Slider.vue │ │ ├── switch │ │ │ ├── index.js │ │ │ └── Switch.vue │ │ ├── menubar │ │ │ ├── MenubarMenu.vue │ │ │ ├── MenubarGroup.vue │ │ │ ├── MenubarShortcut.vue │ │ │ ├── MenubarSub.vue │ │ │ ├── MenubarRadioGroup.vue │ │ │ ├── MenubarLabel.vue │ │ │ ├── MenubarSeparator.vue │ │ │ ├── Menubar.vue │ │ │ ├── index.js │ │ │ ├── MenubarTrigger.vue │ │ │ ├── MenubarItem.vue │ │ │ ├── MenubarSubTrigger.vue │ │ │ ├── MenubarRadioItem.vue │ │ │ ├── MenubarCheckboxItem.vue │ │ │ ├── MenubarContent.vue │ │ │ └── MenubarSubContent.vue │ │ ├── select │ │ │ ├── SelectItemText.vue │ │ │ ├── SelectValue.vue │ │ │ ├── SelectLabel.vue │ │ │ ├── SelectGroup.vue │ │ │ ├── SelectSeparator.vue │ │ │ ├── index.js │ │ │ ├── Select.vue │ │ │ ├── SelectScrollUpButton.vue │ │ │ ├── SelectScrollDownButton.vue │ │ │ ├── SelectTrigger.vue │ │ │ ├── SelectItem.vue │ │ │ └── SelectContent.vue │ │ ├── context-menu │ │ │ ├── ContextMenuGroup.vue │ │ │ ├── ContextMenuShortcut.vue │ │ │ ├── ContextMenuPortal.vue │ │ │ ├── ContextMenu.vue │ │ │ ├── ContextMenuSub.vue │ │ │ ├── ContextMenuTrigger.vue │ │ │ ├── ContextMenuRadioGroup.vue │ │ │ ├── ContextMenuSeparator.vue │ │ │ ├── ContextMenuLabel.vue │ │ │ ├── index.js │ │ │ ├── ContextMenuItem.vue │ │ │ ├── ContextMenuSubTrigger.vue │ │ │ ├── ContextMenuRadioItem.vue │ │ │ ├── ContextMenuCheckboxItem.vue │ │ │ ├── ContextMenuContent.vue │ │ │ └── ContextMenuSubContent.vue │ │ ├── number-field │ │ │ ├── index.js │ │ │ ├── NumberFieldContent.vue │ │ │ ├── NumberFieldInput.vue │ │ │ ├── NumberFieldDecrement.vue │ │ │ ├── NumberFieldIncrement.vue │ │ │ └── NumberField.vue │ │ ├── toast │ │ │ ├── ToastProvider.vue │ │ │ ├── ToastTitle.vue │ │ │ ├── ToastDescription.vue │ │ │ ├── ToastViewport.vue │ │ │ ├── Toaster.vue │ │ │ ├── ToastClose.vue │ │ │ ├── ToastAction.vue │ │ │ ├── Toast.vue │ │ │ ├── index.js │ │ │ └── use-toast.js │ │ └── button │ │ │ ├── Button.vue │ │ │ └── index.js │ ├── ModalRenderer.vue │ ├── modal │ │ ├── ThreeModal.vue │ │ ├── WelcomeModal.vue │ │ ├── RenameSceneModal.vue │ │ ├── SettingsModal.vue │ │ ├── AddBlockModal.vue │ │ └── BaseModal.vue │ ├── NavigationPanel.vue │ ├── NestedDraggable.vue │ └── ParentBlock.vue ├── assets │ ├── FiraCode-Regular.woff2 │ ├── styles │ │ ├── variables.scss │ │ └── main.scss │ ├── eye-show.svg │ ├── index.css │ └── eye-off.svg ├── utils │ ├── gamecontroller-utils.js │ ├── object-utils.js │ └── index.js ├── pages │ ├── VisualizerPage.vue │ └── GuiPage.vue ├── stores │ ├── app.js │ ├── modal.js │ └── hydra.js ├── services │ └── modalRegistry.js ├── main.js ├── App.vue ├── external │ └── gamecontroller.min.js └── constants.js ├── .husky └── pre-commit ├── public └── favicon.ico ├── .lintstagedrc ├── jsconfig.json ├── firebase.json ├── .stylelintrc ├── .gitignore ├── components.json ├── .github └── workflows │ └── firebase-deploy.yml ├── .eslintrc ├── vite.config.js ├── LICENSE ├── index.html ├── README.md ├── package.json └── tailwind.config.js /.prettierignore: -------------------------------------------------------------------------------- 1 | components/ui/**/* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false 3 | } 4 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "hydra-plus" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/ui/input/index.js: -------------------------------------------------------------------------------- 1 | export { default as Input } from './Input.vue'; 2 | -------------------------------------------------------------------------------- /src/components/ui/label/index.js: -------------------------------------------------------------------------------- 1 | export { default as Label } from './Label.vue'; 2 | -------------------------------------------------------------------------------- /src/components/ui/slider/index.js: -------------------------------------------------------------------------------- 1 | export { default as Slider } from './Slider.vue'; 2 | -------------------------------------------------------------------------------- /src/components/ui/switch/index.js: -------------------------------------------------------------------------------- 1 | export { default as Switch } from './Switch.vue'; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahegyi/hydra-plus/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.{js,vue}": ["eslint --fix"], 3 | "**/*.{css,scss,vue}": ["stylelint --fix"] 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/FiraCode-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dahegyi/hydra-plus/HEAD/src/assets/FiraCode-Regular.woff2 -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/assets/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $border-radius: 16px; 2 | $border-radius-sm: 8px; 3 | $border-radius-xs: 4px; 4 | 5 | $color-red: #ef4444; 6 | -------------------------------------------------------------------------------- /src/utils/gamecontroller-utils.js: -------------------------------------------------------------------------------- 1 | export const activeButtons = {}; 2 | 3 | export function isButtonPressed(identifier) { 4 | return !!activeButtons[identifier]; 5 | } 6 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 5 | "rewrites": [ 6 | { 7 | "source": "**", 8 | "destination": "/index.html" 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-idiomatic-order"], 3 | "overrides": [ 4 | { 5 | "files": ["**/*.scss"], 6 | "customSyntax": "postcss-scss" 7 | }, 8 | { 9 | "files": ["**/*.vue"], 10 | "customSyntax": "postcss-html" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarMenu.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarGroup.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectItemText.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | .firebase 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuGroup.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarShortcut.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | -------------------------------------------------------------------------------- /src/components/ui/number-field/index.js: -------------------------------------------------------------------------------- 1 | export { default as NumberField } from './NumberField.vue'; 2 | export { default as NumberFieldContent } from './NumberFieldContent.vue'; 3 | export { default as NumberFieldDecrement } from './NumberFieldDecrement.vue'; 4 | export { default as NumberFieldIncrement } from './NumberFieldIncrement.vue'; 5 | export { default as NumberFieldInput } from './NumberFieldInput.vue'; 6 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuShortcut.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectValue.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuPortal.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-vue.com/schema.json", 3 | "style": "new-york", 4 | "typescript": false, 5 | "tsConfigPath": "./jsconfig.json", 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/assets/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "framework": "vite", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/utils" 17 | } 18 | } -------------------------------------------------------------------------------- /src/components/ui/toast/ToastProvider.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarSub.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /src/pages/VisualizerPage.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/ui/number-field/NumberFieldContent.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 21 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenu.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuSub.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuTrigger.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectLabel.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /.github/workflows/firebase-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Firebase Hosting on merge 2 | "on": 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build_and_deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - run: npm ci && npm run build 12 | - uses: FirebaseExtended/action-hosting-deploy@v0 13 | with: 14 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 15 | firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_HYDRA_PLUS }}" 16 | channelId: live 17 | projectId: hydra-plus 18 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarRadioGroup.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarLabel.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuRadioGroup.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "vue-eslint-parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "sourceType": "module" 6 | }, 7 | "env": { 8 | "browser": true, 9 | "es2020": true, 10 | "node": true 11 | }, 12 | "extends": [ 13 | "plugin:vue/vue3-recommended", 14 | "eslint:recommended", 15 | "plugin:prettier/recommended" 16 | ], 17 | "plugins": ["prettier"], 18 | "rules": { 19 | "prettier/prettier": "error" 20 | }, 21 | "ignorePatterns": [ 22 | "node_modules/*", 23 | ".next/*", 24 | ".out/*", 25 | "!.prettierrc.js", 26 | "src/external/*", 27 | "src/components/ui/*" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectGroup.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectSeparator.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 25 | -------------------------------------------------------------------------------- /src/components/ui/button/Button.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuSeparator.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 25 | -------------------------------------------------------------------------------- /src/components/ui/number-field/NumberFieldInput.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | -------------------------------------------------------------------------------- /src/components/ui/toast/ToastTitle.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 27 | -------------------------------------------------------------------------------- /src/components/ui/toast/ToastDescription.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 27 | -------------------------------------------------------------------------------- /src/assets/styles/main.scss: -------------------------------------------------------------------------------- 1 | @use "variables" as *; 2 | 3 | :root { 4 | background-color: #000; 5 | color: #eee; 6 | color-scheme: dark; 7 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | font-synthesis: none; 11 | font-weight: 400; 12 | line-height: 24px; 13 | text-rendering: optimizeLegibility; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | a { 18 | color: initial; 19 | font-weight: bold; 20 | 21 | &:visited { 22 | color: initial; 23 | } 24 | } 25 | 26 | hr { 27 | width: 100%; 28 | } 29 | 30 | // toast 31 | 32 | ol { 33 | &:focus-visible { 34 | outline: none; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/stores/app.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { ref } from "vue"; 3 | import { getSafeLocalStorage, setSafeLocalStorage } from "@/utils"; 4 | 5 | export const useAppStore = defineStore("app", () => { 6 | const areAnimationsEnabled = ref( 7 | getSafeLocalStorage("animationsEnabled") ?? true, 8 | ); 9 | 10 | const toggleAnimations = () => { 11 | areAnimationsEnabled.value = !areAnimationsEnabled.value; 12 | 13 | document.documentElement.dataset.animations = areAnimationsEnabled.value 14 | ? "on" 15 | : "off"; 16 | 17 | setSafeLocalStorage("animationsEnabled", areAnimationsEnabled.value); 18 | }; 19 | 20 | return { areAnimationsEnabled, toggleAnimations }; 21 | }); 22 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | 4 | import autoprefixer from "autoprefixer"; 5 | import tailwind from "tailwindcss"; 6 | 7 | export default defineConfig(({ mode }) => { 8 | const config = { 9 | define: { 10 | __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, 11 | }, 12 | css: { 13 | postcss: { 14 | plugins: [tailwind(), autoprefixer()], 15 | }, 16 | }, 17 | plugins: [vue()], 18 | resolve: { 19 | alias: { 20 | "@": "/src", 21 | }, 22 | extensions: [".js", ".vue"], 23 | }, 24 | }; 25 | 26 | if (mode === "development") { 27 | config.define.global = {}; 28 | } 29 | 30 | return config; 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarSeparator.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 27 | -------------------------------------------------------------------------------- /src/components/ui/select/index.js: -------------------------------------------------------------------------------- 1 | export { default as Select } from './Select.vue'; 2 | export { default as SelectContent } from './SelectContent.vue'; 3 | export { default as SelectGroup } from './SelectGroup.vue'; 4 | export { default as SelectItem } from './SelectItem.vue'; 5 | export { default as SelectItemText } from './SelectItemText.vue'; 6 | export { default as SelectLabel } from './SelectLabel.vue'; 7 | export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'; 8 | export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'; 9 | export { default as SelectSeparator } from './SelectSeparator.vue'; 10 | export { default as SelectTrigger } from './SelectTrigger.vue'; 11 | export { default as SelectValue } from './SelectValue.vue'; 12 | -------------------------------------------------------------------------------- /src/components/ui/label/Label.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 33 | -------------------------------------------------------------------------------- /src/components/ModalRenderer.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 36 | -------------------------------------------------------------------------------- /src/components/ui/select/Select.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 25 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuLabel.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 34 | -------------------------------------------------------------------------------- /src/components/ui/toast/ToastViewport.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 32 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectScrollUpButton.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectScrollDownButton.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /src/components/ui/menubar/Menubar.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 37 | -------------------------------------------------------------------------------- /src/components/ui/menubar/index.js: -------------------------------------------------------------------------------- 1 | export { default as Menubar } from "./Menubar.vue"; 2 | export { default as MenubarCheckboxItem } from "./MenubarCheckboxItem.vue"; 3 | export { default as MenubarContent } from "./MenubarContent.vue"; 4 | export { default as MenubarGroup } from "./MenubarGroup.vue"; 5 | export { default as MenubarItem } from "./MenubarItem.vue"; 6 | export { default as MenubarLabel } from "./MenubarLabel.vue"; 7 | export { default as MenubarMenu } from "./MenubarMenu.vue"; 8 | export { default as MenubarRadioGroup } from "./MenubarRadioGroup.vue"; 9 | export { default as MenubarRadioItem } from "./MenubarRadioItem.vue"; 10 | export { default as MenubarSeparator } from "./MenubarSeparator.vue"; 11 | export { default as MenubarShortcut } from "./MenubarShortcut.vue"; 12 | export { default as MenubarSub } from "./MenubarSub.vue"; 13 | export { default as MenubarSubContent } from "./MenubarSubContent.vue"; 14 | export { default as MenubarSubTrigger } from "./MenubarSubTrigger.vue"; 15 | export { default as MenubarTrigger } from "./MenubarTrigger.vue"; 16 | -------------------------------------------------------------------------------- /src/components/ui/input/Input.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarTrigger.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 35 | -------------------------------------------------------------------------------- /src/components/ui/toast/Toaster.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 38 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/index.js: -------------------------------------------------------------------------------- 1 | export { default as ContextMenu } from './ContextMenu.vue'; 2 | export { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.vue'; 3 | export { default as ContextMenuContent } from './ContextMenuContent.vue'; 4 | export { default as ContextMenuGroup } from './ContextMenuGroup.vue'; 5 | export { default as ContextMenuItem } from './ContextMenuItem.vue'; 6 | export { default as ContextMenuLabel } from './ContextMenuLabel.vue'; 7 | export { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.vue'; 8 | export { default as ContextMenuRadioItem } from './ContextMenuRadioItem.vue'; 9 | export { default as ContextMenuSeparator } from './ContextMenuSeparator.vue'; 10 | export { default as ContextMenuShortcut } from './ContextMenuShortcut.vue'; 11 | export { default as ContextMenuSub } from './ContextMenuSub.vue'; 12 | export { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'; 13 | export { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.vue'; 14 | export { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'; 15 | -------------------------------------------------------------------------------- /src/components/ui/number-field/NumberFieldDecrement.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 39 | -------------------------------------------------------------------------------- /src/components/ui/number-field/NumberFieldIncrement.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 39 | -------------------------------------------------------------------------------- /src/components/ui/toast/ToastClose.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel Hegyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | hydra+ 7 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 22 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/assets/eye-show.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/ui/toast/ToastAction.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 33 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarItem.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 40 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuItem.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 39 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarSubTrigger.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /src/components/modal/ThreeModal.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 47 | 48 | 58 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuSubTrigger.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /src/components/ui/number-field/NumberField.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 38 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectTrigger.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 39 | -------------------------------------------------------------------------------- /src/assets/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 3.9%; 8 | --foreground: 0 0% 98%; 9 | 10 | --muted: 0 0% 14.9%; 11 | --muted-foreground: 0 0% 63.9%; 12 | 13 | --popover: 0 0% 3.9%; 14 | --popover-foreground: 0 0% 98%; 15 | 16 | --card: 0 0% 3.9%; 17 | --card-foreground: 0 0% 98%; 18 | 19 | --border: 0 0% 14.9%; 20 | --input: 0 0% 14.9%; 21 | 22 | --primary: 0 0% 98%; 23 | --primary-foreground: 0 0% 9%; 24 | 25 | --secondary: 0 0% 14.9%; 26 | --secondary-foreground: 0 0% 98%; 27 | 28 | --accent: 0 0% 14.9%; 29 | --accent-foreground: 0 0% 98%; 30 | 31 | --destructive: 0 62.8% 30.6%; 32 | --destructive-foreground: 0 0% 98%; 33 | 34 | --ring: 0 0% 83.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | } 39 | 40 | @layer base { 41 | * { 42 | @apply border-border; 43 | } 44 | body { 45 | @apply bg-background text-foreground; 46 | } 47 | } 48 | 49 | html[data-animations="off"] * { 50 | animation: none !important; 51 | transition: none !important; 52 | } 53 | 54 | html, 55 | body { 56 | overscroll-behavior-x: none; 57 | } 58 | 59 | @font-face { 60 | font-family: "Fira Code"; 61 | font-style: normal; 62 | font-weight: 400; 63 | src: url("FiraCode-Regular.woff2") format("woff2"); 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hydra+ 2 | 3 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/dahegyi/hydra-plus) 4 | 5 | hydra+ is a double-screen visual editing environment based on the [hydra synth](https://github.com/hydra-synth/hydra-synth), designed for ease of use and to project live visuals without revealing any code. 6 | 7 | _The application is tested only on Chromium-based browsers, so it may not work properly on other browsers._ 8 | 9 | If you have any questions, suggestions, or just want to report a bug, please use the **Issues** or **Discussions** tab on [Github](https://github.com/dahegyi/hydra-plus). 10 | 11 | ## Known issues: 12 | 13 | - initScreen doesn't work properly in visualizer 14 | - initScreen doesn't initialize properly after refreshing the page 15 | 16 | ## Local development 17 | 18 | 1. Clone the repository: `git clone git@github.com:dahegyi/hydra-plus.git` 19 | 2. Install dependencies: `npm install` 20 | 3. Run the development server: `npm run dev` 21 | 22 | ### Usage 23 | 24 | - `npm run dev` - Runs the development server 25 | - `npm run build` - Builds the production version 26 | - `npm run preview` - Serves the production version locally 27 | - `npm run prepare` - Installs the Git hooks (runs automatically on `npm install`) 28 | - `npm run lint` - Runs ESLint and Stylelint 29 | - `npm run lint:fix` - Runs ESLint and Stylelint and fixes the errors 30 | -------------------------------------------------------------------------------- /src/services/modalRegistry.js: -------------------------------------------------------------------------------- 1 | import { defineAsyncComponent } from "vue"; 2 | 3 | const modalRegistry = new Map(); 4 | 5 | export const registerModal = (name, component) => { 6 | modalRegistry.set(name, component); 7 | }; 8 | 9 | export const getModalComponent = (name) => { 10 | return modalRegistry.get(name); 11 | }; 12 | 13 | export const getAllModals = () => { 14 | return Object.fromEntries(modalRegistry); 15 | }; 16 | 17 | export const isModalRegistered = (name) => { 18 | return modalRegistry.has(name); 19 | }; 20 | 21 | export const initializeModalRegistry = () => { 22 | registerModal( 23 | "welcome", 24 | defineAsyncComponent(() => import("@/components/modal/WelcomeModal")), 25 | ); 26 | registerModal( 27 | "addBlock", 28 | defineAsyncComponent(() => import("@/components/modal/AddBlockModal")), 29 | ); 30 | registerModal( 31 | "three", 32 | defineAsyncComponent(() => import("@/components/modal/ThreeModal")), 33 | ); 34 | registerModal( 35 | "settings", 36 | defineAsyncComponent(() => import("@/components/modal/SettingsModal")), 37 | ); 38 | registerModal( 39 | "renameScene", 40 | defineAsyncComponent(() => import("@/components/modal/RenameSceneModal")), 41 | ); 42 | }; 43 | 44 | export default { 45 | registerModal, 46 | getModalComponent, 47 | getAllModals, 48 | isModalRegistered, 49 | initializeModalRegistry, 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/ui/toast/Toast.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 49 | -------------------------------------------------------------------------------- /src/components/ui/button/index.js: -------------------------------------------------------------------------------- 1 | import { cva } from 'class-variance-authority'; 2 | 3 | export { default as Button } from './Button.vue'; 4 | 5 | export const buttonVariants = cva( 6 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 7 | { 8 | variants: { 9 | variant: { 10 | default: 11 | 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 12 | destructive: 13 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 14 | outline: 15 | 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 16 | secondary: 17 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 18 | ghost: 'hover:bg-accent hover:text-accent-foreground', 19 | link: 'text-primary underline-offset-4 hover:underline', 20 | }, 21 | size: { 22 | default: 'h-9 px-4 py-2', 23 | xs: 'h-7 rounded px-2', 24 | sm: 'h-8 rounded-md px-3 text-xs', 25 | lg: 'h-10 rounded-md px-8', 26 | icon: 'h-9 w-9', 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: 'default', 31 | size: 'default', 32 | }, 33 | }, 34 | ); 35 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectItem.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 51 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarRadioItem.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 48 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarCheckboxItem.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 48 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuRadioItem.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 48 | -------------------------------------------------------------------------------- /src/components/ui/toast/index.js: -------------------------------------------------------------------------------- 1 | export { default as Toast } from './Toast.vue'; 2 | export { default as ToastAction } from './ToastAction.vue'; 3 | export { default as ToastClose } from './ToastClose.vue'; 4 | export { default as ToastDescription } from './ToastDescription.vue'; 5 | export { default as Toaster } from './Toaster.vue'; 6 | export { default as ToastProvider } from './ToastProvider.vue'; 7 | export { default as ToastTitle } from './ToastTitle.vue'; 8 | export { default as ToastViewport } from './ToastViewport.vue'; 9 | export { toast, useToast } from './use-toast'; 10 | 11 | import { cva } from 'class-variance-authority'; 12 | 13 | export const toastVariants = cva( 14 | 'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', 15 | { 16 | variants: { 17 | variant: { 18 | default: 'border bg-background text-foreground', 19 | destructive: 20 | 'destructive group border-destructive bg-destructive text-destructive-foreground', 21 | }, 22 | }, 23 | defaultVariants: { 24 | variant: 'default', 25 | }, 26 | }, 27 | ); 28 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuCheckboxItem.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 48 | -------------------------------------------------------------------------------- /src/assets/eye-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hydra-plus", 3 | "private": true, 4 | "version": "0.9.2", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "prepare": "husky install", 11 | "lint": "eslint 'src/**/*.{js,vue}' && stylelint 'src/**/*.{css,scss,vue}'", 12 | "lint:fix": "eslint 'src/**/*.{js,vue}' --fix && stylelint 'src/**/*.{css,scss,vue}' --fix" 13 | }, 14 | "dependencies": { 15 | "@radix-icons/vue": "^1.0.0", 16 | "@vueuse/core": "^10.11.1", 17 | "class-variance-authority": "^0.7.1", 18 | "clsx": "^2.1.1", 19 | "codejar": "^4.2.0", 20 | "crc-32": "^1.2.2", 21 | "gamecontroller.js": "^1.5.0", 22 | "hydra-synth": "^1.3.29", 23 | "js-beautify": "^1.15.4", 24 | "lucide-vue-next": "^0.475.0", 25 | "moment": "^2.30.1", 26 | "pinia": "^2.3.1", 27 | "radix-vue": "^1.9.15", 28 | "sass": "^1.67.0", 29 | "tailwind-merge": "^3.0.1", 30 | "tailwindcss-animate": "^1.0.7", 31 | "three": "^0.156.1", 32 | "vue": "^3.3.4", 33 | "vue-router": "^4.2.4", 34 | "vuedraggable": "^4.1.0" 35 | }, 36 | "devDependencies": { 37 | "@vitejs/plugin-vue": "^6.0.1", 38 | "autoprefixer": "^10.4.20", 39 | "eslint": "^8.51.0", 40 | "eslint-config-prettier": "^9.0.0", 41 | "eslint-plugin-prettier": "^5.0.1", 42 | "eslint-plugin-vue": "^9.17.0", 43 | "husky": "^8.0.3", 44 | "lint-staged": "^15.0.2", 45 | "postcss-html": "^1.5.0", 46 | "postcss-scss": "^4.0.9", 47 | "prettier": "^3.0.3", 48 | "stylelint": "^14.16.1", 49 | "stylelint-config-idiomatic-order": "^9.0.0", 50 | "tailwindcss": "^3.4.17", 51 | "vite": "^7.1.4" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/ui/switch/Switch.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 51 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuContent.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 56 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarContent.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 50 | -------------------------------------------------------------------------------- /src/components/ui/context-menu/ContextMenuSubContent.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 55 | -------------------------------------------------------------------------------- /src/stores/modal.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { ref, computed } from "vue"; 3 | 4 | export const useModalStore = defineStore("modal", () => { 5 | const modals = ref(new Map()); 6 | const modalData = ref(new Map()); 7 | 8 | const isAnyModalOpen = computed(() => { 9 | return Array.from(modals.value.values()).some((isOpen) => isOpen); 10 | }); 11 | 12 | const getModalState = (modalName) => { 13 | return modals.value.get(modalName) || false; 14 | }; 15 | 16 | const getModalData = (modalName) => { 17 | return modalData.value.get(modalName) || {}; 18 | }; 19 | 20 | const openModal = (modalName, data = null) => { 21 | modals.value.set(modalName, true); 22 | 23 | if (data) { 24 | modalData.value.set(modalName, { ...getModalData(modalName), ...data }); 25 | } 26 | }; 27 | 28 | const closeModal = (modalName) => { 29 | modals.value.set(modalName, false); 30 | 31 | const currentData = getModalData(modalName); 32 | if (currentData && Object.keys(currentData).length > 0) { 33 | const resetData = Object.keys(currentData).reduce((acc, key) => { 34 | acc[key] = null; 35 | return acc; 36 | }, {}); 37 | modalData.value.set(modalName, resetData); 38 | } 39 | }; 40 | 41 | const closeAllModals = () => { 42 | modals.value.clear(); 43 | modalData.value.clear(); 44 | }; 45 | 46 | const modalStates = computed(() => { 47 | return { 48 | welcome: getModalState("welcome"), 49 | addBlock: getModalState("addBlock"), 50 | three: getModalState("three"), 51 | settings: getModalState("settings"), 52 | renameScene: getModalState("renameScene"), 53 | }; 54 | }); 55 | 56 | const modalDataObj = computed(() => { 57 | return { 58 | renameScene: getModalData("renameScene"), 59 | addBlock: getModalData("addBlock"), 60 | }; 61 | }); 62 | 63 | return { 64 | // State 65 | modals, 66 | modalData, 67 | isAnyModalOpen, 68 | modalStates, 69 | modalDataObj, 70 | 71 | // Actions 72 | openModal, 73 | closeModal, 74 | closeAllModals, 75 | getModalState, 76 | getModalData, 77 | }; 78 | }); 79 | -------------------------------------------------------------------------------- /src/components/ui/menubar/MenubarSubContent.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 62 | -------------------------------------------------------------------------------- /src/components/ui/slider/Slider.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 63 | -------------------------------------------------------------------------------- /src/components/modal/WelcomeModal.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 63 | 64 | 96 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { createRouter, createWebHistory } from "vue-router"; 3 | import { createPinia } from "pinia"; 4 | import "./assets/styles/main.scss"; 5 | import "./assets/index.css"; 6 | import App from "./App"; 7 | import { initializeModalRegistry } from "./services/modalRegistry"; 8 | // import { isButtonPressed, activeButtons } from "./utils/gamecontroller-utils"; 9 | 10 | const pinia = createPinia(); 11 | 12 | const GuiPage = () => import("./pages/GuiPage"); 13 | const VisualizerPage = () => import("./pages/VisualizerPage"); 14 | 15 | const routes = [ 16 | { path: "/", component: GuiPage }, 17 | { path: "/visualizer", component: VisualizerPage }, 18 | ]; 19 | 20 | const router = createRouter({ 21 | history: createWebHistory(), 22 | routes, 23 | }); 24 | 25 | // window.isButtonPressed = isButtonPressed; 26 | 27 | document.addEventListener("contextmenu", (event) => { 28 | if (process.env.NODE_ENV === "production") event.preventDefault(); 29 | window.contextMenuPosition = { 30 | x: event.clientX + window.scrollX, 31 | y: event.clientY + window.scrollY, 32 | }; 33 | }); 34 | 35 | /* eslint-disable-next-line no-undef */ 36 | // gameControl.on("connect", function (gamepad) { 37 | // const buttons = [ 38 | // { id: "button1", name: "1" }, 39 | // { id: "button2", name: "2" }, 40 | // { id: "button3", name: "3" }, 41 | // { id: "button4", name: "4" }, 42 | // { id: "button5", name: "5" }, 43 | // { id: "button6", name: "6" }, 44 | // { id: "button7", name: "7" }, 45 | // { id: "button8", name: "8" }, 46 | // { id: "button9", name: "9" }, 47 | // { id: "button10", name: "10" }, 48 | // { id: "button11", name: "11" }, 49 | // { id: "button12", name: "12" }, 50 | // { id: "button13", name: "13" }, 51 | // { id: "button14", name: "14" }, 52 | // { id: "button15", name: "15" }, 53 | // { id: "left0", name: "left0" }, 54 | // { id: "right0", name: "right0" }, 55 | // { id: "up0", name: "up0" }, 56 | // { id: "down0", name: "down0" }, 57 | // { id: "left1", name: "left1" }, 58 | // { id: "right1", name: "right1" }, 59 | // { id: "up1", name: "up1" }, 60 | // { id: "down1", name: "down1" }, 61 | // ]; 62 | 63 | // buttons.forEach(({ id, name }) => { 64 | // gamepad 65 | // .before(id, () => { 66 | // activeButtons[name] = true; 67 | // console.log(`${name} pressed`); 68 | // }) 69 | // .after(id, () => { 70 | // activeButtons[name] = false; 71 | // }); 72 | // }); 73 | // }); 74 | 75 | initializeModalRegistry(); 76 | 77 | createApp(App).use(router).use(pinia).mount("#app"); 78 | -------------------------------------------------------------------------------- /src/components/modal/RenameSceneModal.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 102 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const animate = require("tailwindcss-animate"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: ["class"], 6 | safelist: ["dark"], 7 | prefix: "", 8 | 9 | content: [ 10 | "./pages/**/*.{js,jsx,vue}", 11 | "./components/**/*.{js,jsx,vue}", 12 | "./app/**/*.{js,jsx,vue}", 13 | "./src/**/*.{js,jsx,vue}", 14 | ], 15 | 16 | theme: { 17 | container: { 18 | center: true, 19 | padding: "2rem", 20 | screens: { 21 | "2xl": "1400px", 22 | }, 23 | }, 24 | extend: { 25 | colors: { 26 | border: "hsl(var(--border))", 27 | input: "hsl(var(--input))", 28 | ring: "hsl(var(--ring))", 29 | background: "hsl(var(--background))", 30 | foreground: "hsl(var(--foreground))", 31 | primary: { 32 | DEFAULT: "hsl(var(--primary))", 33 | foreground: "hsl(var(--primary-foreground))", 34 | }, 35 | secondary: { 36 | DEFAULT: "hsl(var(--secondary))", 37 | foreground: "hsl(var(--secondary-foreground))", 38 | }, 39 | destructive: { 40 | DEFAULT: "hsl(var(--destructive))", 41 | foreground: "hsl(var(--destructive-foreground))", 42 | }, 43 | muted: { 44 | DEFAULT: "hsl(var(--muted))", 45 | foreground: "hsl(var(--muted-foreground))", 46 | }, 47 | accent: { 48 | DEFAULT: "hsl(var(--accent))", 49 | foreground: "hsl(var(--accent-foreground))", 50 | }, 51 | popover: { 52 | DEFAULT: "hsl(var(--popover))", 53 | foreground: "hsl(var(--popover-foreground))", 54 | }, 55 | card: { 56 | DEFAULT: "hsl(var(--card))", 57 | foreground: "hsl(var(--card-foreground))", 58 | }, 59 | }, 60 | borderRadius: { 61 | xl: "calc(var(--radius) + 4px)", 62 | lg: "var(--radius)", 63 | md: "calc(var(--radius) - 2px)", 64 | sm: "calc(var(--radius) - 4px)", 65 | }, 66 | keyframes: { 67 | "accordion-down": { 68 | from: { height: 0 }, 69 | to: { height: "var(--radix-accordion-content-height)" }, 70 | }, 71 | "accordion-up": { 72 | from: { height: "var(--radix-accordion-content-height)" }, 73 | to: { height: 0 }, 74 | }, 75 | "collapsible-down": { 76 | from: { height: 0 }, 77 | to: { height: "var(--radix-collapsible-content-height)" }, 78 | }, 79 | "collapsible-up": { 80 | from: { height: "var(--radix-collapsible-content-height)" }, 81 | to: { height: 0 }, 82 | }, 83 | }, 84 | animation: { 85 | "accordion-down": "accordion-down 0.2s ease-out", 86 | "accordion-up": "accordion-up 0.2s ease-out", 87 | "collapsible-down": "collapsible-down 0.2s ease-in-out", 88 | "collapsible-up": "collapsible-up 0.2s ease-in-out", 89 | }, 90 | }, 91 | }, 92 | plugins: [animate], 93 | }; 94 | -------------------------------------------------------------------------------- /src/components/ui/select/SelectContent.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 80 | -------------------------------------------------------------------------------- /src/utils/object-utils.js: -------------------------------------------------------------------------------- 1 | import { TYPE_SRC, TYPE_COMPLEX, PARAM_MAPPINGS } from "@/constants"; 2 | import beautify from "js-beautify"; 3 | import moment from "moment"; 4 | 5 | export const deepCopy = (obj) => { 6 | return JSON.parse(JSON.stringify(obj)); 7 | }; 8 | 9 | export const flattenExternal = (obj, index) => { 10 | return `s${index}.${obj.name}(${ 11 | obj.params && obj.params[0] ? `"${obj.params[0]}"` : "" 12 | })\n`; 13 | }; 14 | 15 | /** 16 | * Returns a block with mapped parameters 17 | */ 18 | export const mapParams = (blocks) => { 19 | const mappedBlocks = []; 20 | 21 | if (!blocks) { 22 | return; 23 | } 24 | 25 | for (const block of blocks) { 26 | const mapping = PARAM_MAPPINGS[block.name]; 27 | 28 | const mappedParams = block.params.map((value, index) => ({ 29 | name: mapping[index], 30 | value, 31 | })); 32 | 33 | const mappedChildren = mapParams(block.blocks); 34 | 35 | mappedBlocks.push({ 36 | ...block, 37 | params: mappedParams, 38 | blocks: mappedChildren, 39 | }); 40 | } 41 | 42 | return mappedBlocks; 43 | }; 44 | 45 | /** 46 | * Returns hydra code from codeblox 47 | */ 48 | export const flatten = (obj) => { 49 | let source = ""; 50 | 51 | if (Array.isArray(obj)) { 52 | for (let item of obj) { 53 | source += flatten(item); 54 | } 55 | } else { 56 | // Sources are top level functions 57 | if (obj.type !== TYPE_SRC) { 58 | source += "."; 59 | } 60 | 61 | source += `${obj.name}(`; 62 | 63 | if (obj.type === TYPE_COMPLEX) { 64 | // Modulation requires a source as a parameter 65 | if (obj.blocks.length > 0) { 66 | source += flatten(obj.blocks[0]); 67 | source += ","; 68 | } else { 69 | // fallback to osc 70 | source += "osc(10, 0.1, 0),"; 71 | } 72 | } 73 | 74 | for (const [i, param] of obj.params?.entries() || []) { 75 | source += param; 76 | 77 | if (i < obj.params.length - 1) { 78 | source += ","; 79 | } 80 | } 81 | 82 | source += ")"; 83 | 84 | if (obj.type !== TYPE_COMPLEX && obj.blocks?.length > 0) { 85 | source += flatten(obj.blocks); 86 | } 87 | } 88 | 89 | return source; 90 | }; 91 | 92 | export const downloadBeautifiedCode = (codeString) => { 93 | const beautifiedCode = beautify.js(codeString, { 94 | indent_size: 2, 95 | space_in_empty_paren: true, 96 | preserve_newlines: true, 97 | max_preserve_newlines: 2, 98 | keep_array_indentation: true, 99 | break_chained_methods: true, 100 | }); 101 | 102 | const blob = new Blob([beautifiedCode], { type: "text/plain" }); 103 | const url = URL.createObjectURL(blob); 104 | const formattedDatetime = moment().format("YYYY-MM-DD__HH-mm-ss"); 105 | const a = document.createElement("a"); 106 | a.href = url; 107 | a.download = `hydra-plus-export-${formattedDatetime}.txt`; 108 | a.click(); 109 | URL.revokeObjectURL(url); 110 | }; 111 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 95 | 96 | 104 | -------------------------------------------------------------------------------- /src/components/ui/toast/use-toast.js: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue'; 2 | 3 | const TOAST_LIMIT = 1; 4 | const TOAST_REMOVE_DELAY = 1000000; 5 | 6 | const actionTypes = { 7 | ADD_TOAST: 'ADD_TOAST', 8 | UPDATE_TOAST: 'UPDATE_TOAST', 9 | DISMISS_TOAST: 'DISMISS_TOAST', 10 | REMOVE_TOAST: 'REMOVE_TOAST', 11 | }; 12 | 13 | let count = 0; 14 | 15 | function genId() { 16 | count = (count + 1) % Number.MAX_VALUE; 17 | return count.toString(); 18 | } 19 | 20 | const toastTimeouts = new Map(); 21 | 22 | function addToRemoveQueue(toastId) { 23 | if (toastTimeouts.has(toastId)) return; 24 | 25 | const timeout = setTimeout(() => { 26 | toastTimeouts.delete(toastId); 27 | dispatch({ 28 | type: actionTypes.REMOVE_TOAST, 29 | toastId, 30 | }); 31 | }, TOAST_REMOVE_DELAY); 32 | 33 | toastTimeouts.set(toastId, timeout); 34 | } 35 | 36 | const state = ref({ 37 | toasts: [], 38 | }); 39 | 40 | function dispatch(action) { 41 | switch (action.type) { 42 | case actionTypes.ADD_TOAST: 43 | state.value.toasts = [action.toast, ...state.value.toasts].slice( 44 | 0, 45 | TOAST_LIMIT, 46 | ); 47 | break; 48 | 49 | case actionTypes.UPDATE_TOAST: 50 | state.value.toasts = state.value.toasts.map((t) => 51 | t.id === action.toast.id ? { ...t, ...action.toast } : t, 52 | ); 53 | break; 54 | 55 | case actionTypes.DISMISS_TOAST: { 56 | const { toastId } = action; 57 | 58 | if (toastId) { 59 | addToRemoveQueue(toastId); 60 | } else { 61 | state.value.toasts.forEach((toast) => { 62 | addToRemoveQueue(toast.id); 63 | }); 64 | } 65 | 66 | state.value.toasts = state.value.toasts.map((t) => 67 | t.id === toastId || toastId === undefined 68 | ? { 69 | ...t, 70 | open: false, 71 | } 72 | : t, 73 | ); 74 | break; 75 | } 76 | 77 | case actionTypes.REMOVE_TOAST: 78 | if (action.toastId === undefined) state.value.toasts = []; 79 | else 80 | state.value.toasts = state.value.toasts.filter( 81 | (t) => t.id !== action.toastId, 82 | ); 83 | 84 | break; 85 | } 86 | } 87 | 88 | function useToast() { 89 | return { 90 | toasts: computed(() => state.value.toasts), 91 | toast, 92 | dismiss: (toastId) => 93 | dispatch({ type: actionTypes.DISMISS_TOAST, toastId }), 94 | }; 95 | } 96 | 97 | function toast(props) { 98 | const id = genId(); 99 | 100 | const update = (props) => 101 | dispatch({ 102 | type: actionTypes.UPDATE_TOAST, 103 | toast: { ...props, id }, 104 | }); 105 | 106 | const dismiss = () => 107 | dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id }); 108 | 109 | dispatch({ 110 | type: actionTypes.ADD_TOAST, 111 | toast: { 112 | ...props, 113 | id, 114 | open: true, 115 | onOpenChange: (open) => { 116 | if (!open) dismiss(); 117 | }, 118 | }, 119 | }); 120 | 121 | return { 122 | id, 123 | dismiss, 124 | update, 125 | }; 126 | } 127 | 128 | export { toast, useToast }; 129 | -------------------------------------------------------------------------------- /src/components/modal/SettingsModal.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | import crc32 from "crc-32"; 4 | 5 | import { useToast } from "@/components/ui/toast/use-toast"; 6 | const { toast } = useToast(); 7 | 8 | export function cn(...inputs) { 9 | return twMerge(clsx(inputs)); 10 | } 11 | 12 | const safeKeys = [ 13 | "blocks", // can remove later 14 | "externalSourceBlocks", // can remove later 15 | "synthSettings", 16 | "welcomeModalLastUpdate", 17 | "animationsEnabled", 18 | "scenes", 19 | "currentSceneId", 20 | ]; 21 | 22 | export const getSafeLocalStorage = (key) => { 23 | if (safeKeys.includes(key)) { 24 | if (localStorage.getItem(key)) { 25 | try { 26 | return JSON.parse(localStorage.getItem(key)); 27 | } catch { 28 | return localStorage.getItem(key); 29 | } 30 | } 31 | return; 32 | } else { 33 | return console.error(`Key not found in localStorage safe keys: ${key}`); 34 | } 35 | }; 36 | 37 | export const setSafeLocalStorage = (key, value) => { 38 | if (safeKeys.includes(key)) { 39 | if (typeof value === "object") { 40 | localStorage.setItem(key, JSON.stringify(value)); 41 | } else { 42 | localStorage.setItem(key, value); 43 | } 44 | } 45 | }; 46 | 47 | export const showErrorToast = (error) => { 48 | console.error(error); 49 | toast({ 50 | title: "Error", 51 | description: error, 52 | variant: "destructive", 53 | }); 54 | }; 55 | 56 | // Generate a hash from the path 57 | export const generateUniqueId = (path) => { 58 | const hash = crc32.str(path).toString(16); // Convert to hexadecimal 59 | return `id-${hash}`; // Prefix with 'id-' to ensure it's a valid HTML ID 60 | }; 61 | 62 | export const setHueLights = async (state) => { 63 | const bridgeIp = "192.168.2.1"; 64 | const username = "5Vk5HtnmgO-LLw2ai36FprAn2shzwBItE3ubZIlT"; 65 | const lightLength = 6; 66 | 67 | const xy = (red, green, blue) => { 68 | red = 69 | red > 0.04045 70 | ? Math.pow((red + 0.055) / (1.0 + 0.055), 2.4) 71 | : red / 12.92; 72 | 73 | green = 74 | green > 0.04045 75 | ? Math.pow((green + 0.055) / (1.0 + 0.055), 2.4) 76 | : green / 12.92; 77 | 78 | blue = 79 | blue > 0.04045 80 | ? Math.pow((blue + 0.055) / (1.0 + 0.055), 2.4) 81 | : blue / 12.92; 82 | 83 | const X = red * 0.664511 + green * 0.154324 + blue * 0.162028; 84 | const Y = red * 0.283881 + green * 0.668433 + blue * 0.047685; 85 | const Z = red * 0.000088 + green * 0.07231 + blue * 0.986039; 86 | 87 | const fx = X / (X + Y + Z); 88 | const fy = Y / (X + Y + Z); 89 | 90 | return [parseFloat(fx.toPrecision(4)), parseFloat(fy.toPrecision(4))]; 91 | }; 92 | 93 | for (let i = 1; i <= lightLength; i++) { 94 | const url = `http://${bridgeIp}/api/${username}/lights/${i}/state`; 95 | 96 | try { 97 | const response = await fetch(url, { 98 | method: "PUT", 99 | headers: { 100 | "Content-Type": "application/json", 101 | }, 102 | body: JSON.stringify({ 103 | on: true, 104 | bri: 255, 105 | xy: xy(state.r, state.g, state.b), 106 | }), 107 | }); 108 | const data = await response.json(); 109 | console.log(data); 110 | } catch (error) { 111 | showErrorToast(error); 112 | } 113 | } 114 | }; 115 | -------------------------------------------------------------------------------- /src/components/modal/AddBlockModal.vue: -------------------------------------------------------------------------------- 1 | 110 | 111 | 131 | 132 | 173 | -------------------------------------------------------------------------------- /src/components/modal/BaseModal.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 85 | 86 | 183 | -------------------------------------------------------------------------------- /src/external/gamecontroller.min.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";const t=(t,e="log")=>{"error"===e?console&&"function"==typeof console.error&&console.error(t):console&&"function"==typeof console.info&&console.info(t)},e=e=>t(e,"error"),n=()=>navigator.getGamepads&&"function"==typeof navigator.getGamepads||navigator.getGamepads&&"function"==typeof navigator.webkitGetGamepads||!1,o="Invalid property.",i="Invalid value. It must be a number between 0.00 and 1.00.",s="Button does not exist.",a="Unknown event name.",c=function(t){let n={id:t.index,buttons:t.buttons.length,axes:Math.floor(t.axes.length/2),axeValues:[],axeThreshold:[1],hapticActuator:null,vibrationMode:-1,vibration:!1,mapping:t.mapping,buttonActions:{},axesActions:{},pressed:{},set:function(t,n){if(["axeThreshold"].indexOf(t)>=0){if("axeThreshold"===t&&(!parseFloat(n)||n<0||n>1))return void e(i);this[t]=n}else e(o)},vibrate:function(t=.75,e=500){if(this.hapticActuator)switch(this.vibrationMode){case 0:return this.hapticActuator.pulse(t,e);case 1:return this.hapticActuator.playEffect("dual-rumble",{duration:e,strongMagnitude:t,weakMagnitude:t})}},triggerDirectionalAction:function(t,e,n,o,i){n&&o%2===i?(this.pressed[`${t}${e}`]||(this.pressed[`${t}${e}`]=!0,this.axesActions[e][t].before()),this.axesActions[e][t].action()):this.pressed[`${t}${e}`]&&o%2===i&&(delete this.pressed[`${t}${e}`],this.axesActions[e][t].after())},checkStatus:function(){let t={};const e=navigator.getGamepads?navigator.getGamepads():navigator.webkitGetGamepads?navigator.webkitGetGamepads():[];if(e.length){if(t=e[this.id],t.buttons)for(let e=0;e=this.axeThreshold[0],n,0),this.triggerDirectionalAction("left",i,o<=-this.axeThreshold[0],n,0),this.triggerDirectionalAction("down",i,o>=this.axeThreshold[0],n,1),this.triggerDirectionalAction("up",i,o<=-this.axeThreshold[0],n,1)}}}},associateEvent:function(t,n,o){if(t.match(/^button\d+$/)){const i=parseInt(t.match(/^button(\d+)$/)[1]);i>=0&&i=17?this.buttonActions[16][o]=n:e(s);else if(t.match(/^(up|down|left|right)(\d+)$/)){const i=t.match(/^(up|down|left|right)(\d+)$/),a=i[1],c=parseInt(i[2]);c>=0&&c{},after:()=>{},before:()=>{}};for(let t=0;t{},after:()=>{},before:()=>{}},left:{action:()=>{},after:()=>{},before:()=>{}},right:{action:()=>{},after:()=>{},before:()=>{}},up:{action:()=>{},after:()=>{},before:()=>{}}},n.axeValues[t]=[0,0];return t.hapticActuators?"function"==typeof t.hapticActuators.pulse?(n.hapticActuator=t.hapticActuators,n.vibrationMode=0,n.vibration=!0):t.hapticActuators[0]&&"function"==typeof t.hapticActuators[0].pulse&&(n.hapticActuator=t.hapticActuators[0],n.vibrationMode=0,n.vibration=!0):t.vibrationActuator&&"function"==typeof t.vibrationActuator.playEffect&&(n.hapticActuator=t.vibrationActuator,n.vibrationMode=1,n.vibration=!0),n},r={gamepads:{},axeThreshold:[1],isReady:n(),onConnect:function(){},onDisconnect:function(){},onBeforeCycle:function(){},onAfterCycle:function(){},getGamepads:function(){return this.gamepads},getGamepad:function(t){return this.gamepads[t]?this.gamepads[t]:null},set:function(t,n){if(["axeThreshold"].indexOf(t)>=0){if("axeThreshold"===t&&(!parseFloat(n)||n<0||n>1))return void e(i);if(this[t]=n,"axeThreshold"===t){const t=this.getGamepads(),e=Object.keys(t);for(let n=0;n0&&t(r.checkStatus)},init:function(){window.addEventListener("gamepadconnected",(e=>{const n=e.gamepad||e.detail.gamepad;if(t("Gamepad detected."),window.gamepads||(window.gamepads={}),n){if(!window.gamepads[n.index]){window.gamepads[n.index]=n;const t=c(n);t.set("axeThreshold",this.axeThreshold),this.gamepads[t.id]=t,this.onConnect(this.gamepads[t.id])}1===Object.keys(this.gamepads).length&&this.checkStatus()}})),window.addEventListener("gamepaddisconnected",(e=>{const n=e.gamepad||e.detail.gamepad;t("Gamepad disconnected."),n&&(delete window.gamepads[n.index],delete this.gamepads[n.index],this.onDisconnect(n.index))}))},on:function(t,n){switch(t){case"connect":this.onConnect=n;break;case"disconnect":this.onDisconnect=n;break;case"beforeCycle":case"beforecycle":this.onBeforeCycle=n;break;case"afterCycle":case"aftercycle":this.onAfterCycle=n;break;default:e(a)}return this},off:function(t){switch(t){case"connect":this.onConnect=function(){};break;case"disconnect":this.onDisconnect=function(){};break;case"beforeCycle":case"beforecycle":this.onBeforeCycle=function(){};break;case"afterCycle":case"aftercycle":this.onAfterCycle=function(){};break;default:e(a)}return this}};r.init();const h=r;n()?window.gameControl=h:e("Your web browser does not support the Gamepad API.")})(); -------------------------------------------------------------------------------- /src/components/NavigationPanel.vue: -------------------------------------------------------------------------------- 1 | 158 | 159 | 275 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const CURRENT_VERSION = "0.9.2"; 2 | 3 | export const MODIFIER_KEY = /Macintosh|Mac OS X/i.test(navigator.userAgent) 4 | ? "⌘" 5 | : "Ctrl"; 6 | 7 | export const DEFAULT_POSITION = { x: 15, y: 65 }; 8 | 9 | export const INITIAL_BLOCKS = [ 10 | { 11 | name: "osc", 12 | params: [10, 0.001, 2], 13 | type: "source", 14 | blocks: [ 15 | { 16 | name: "posterize", 17 | params: [1, 0.00001], 18 | type: "simple", 19 | }, 20 | { 21 | name: "modulateRotate", 22 | blocks: [ 23 | { 24 | name: "osc", 25 | params: [8, 0.015, 0], 26 | type: "source", 27 | blocks: [ 28 | { 29 | name: "mask", 30 | blocks: [ 31 | { 32 | name: "shape", 33 | params: [-2, 0.4, 0], 34 | type: "source", 35 | blocks: [], 36 | }, 37 | ], 38 | type: "complex", 39 | }, 40 | ], 41 | }, 42 | ], 43 | params: [8, 0], 44 | type: "complex", 45 | }, 46 | { 47 | name: "colorama", 48 | params: [0.05], 49 | type: "simple", 50 | }, 51 | ], 52 | position: DEFAULT_POSITION, 53 | colorId: 0, 54 | }, 55 | ]; 56 | 57 | export const MAX_NUMBER_OF_SOURCES = 8; 58 | export const MAX_NUMBER_OF_EXTERNALS = 8; 59 | 60 | export const TYPE_SRC = "source"; 61 | export const TYPE_EXTERNAL = "external"; 62 | export const TYPE_THREE = "three"; 63 | export const TYPE_SIMPLE = "simple"; 64 | export const TYPE_COMPLEX = "complex"; 65 | 66 | export const PARAM_MAPPINGS = { 67 | /* Sources */ 68 | noise: ["scale", "offset"], 69 | voronoi: ["scale", "speed", "blending"], 70 | osc: ["frequency", "sync", "offset"], 71 | shape: ["sides", "radius", "smoothing"], 72 | gradient: ["speed"], 73 | src: ["tex"], 74 | solid: ["r", "g", "b", "a"], 75 | /* External */ 76 | initCam: ["index"], 77 | initImage: ["url"], 78 | initVideo: ["url"], 79 | initScreen: [], 80 | "3D": ["scene"], 81 | /* Geometry */ 82 | repeat: ["repeatX", "repeatY", "offsetX", "offsetY"], 83 | repeatX: ["reps", "offset"], 84 | repeatY: ["reps", "offset"], 85 | scroll: ["scrollX", "scrollY", "speedX", "speedY"], 86 | scrollX: ["scrollX", "speed"], 87 | scrollY: ["scrollY", "speed"], 88 | rotate: ["angle", "speed"], 89 | scale: ["amount", "xMult", "yMult", "offsetX", "offsetY"], 90 | pixelate: ["pixelX", "pixelY"], 91 | kaleid: ["nSides"], 92 | /* Color */ 93 | r: ["scale", "offset"], 94 | g: ["scale", "offset"], 95 | b: ["scale", "offset"], 96 | color: ["r", "g", "b", "a"], 97 | saturate: ["amount"], 98 | hue: ["hue"], 99 | posterize: ["bins", "gamma"], 100 | shift: ["r", "g", "b", "a"], 101 | invert: ["amount"], 102 | contrast: ["amount"], 103 | brightness: ["amount"], 104 | luma: ["threshold", "tolerance"], 105 | thresh: ["threshold", "tolerance"], 106 | colorama: ["amount"], 107 | /* Modulate */ 108 | modulateRepeat: ["repeatX", "repeatY", "offsetX", "offsetY"], 109 | modulateRepeatX: ["reps", "offset"], 110 | modulateRepeatY: ["reps", "offset"], 111 | modulateKaleid: ["nSides"], 112 | modulateScrollX: ["scrollX", "speed"], 113 | modulateScrollY: ["scrollY", "speed"], 114 | modulate: ["amount"], 115 | modulateScale: ["multiple", "offset"], 116 | modulatePixelate: ["multiple", "offset"], 117 | modulateRotate: ["multiple", "offset"], 118 | modulateHue: ["amount"], 119 | /* Blend */ 120 | add: ["amount"], 121 | sub: ["amount"], 122 | layer: [], 123 | blend: ["amount"], 124 | mult: ["amount"], 125 | diff: [], 126 | mask: [], 127 | }; 128 | 129 | export const FALLBACK_SOURCE = ""; 130 | 131 | export const SOURCE_FUNCTIONS = [ 132 | { 133 | name: "noise", 134 | params: [10, 0.01], 135 | }, 136 | { 137 | name: "voronoi", 138 | params: [5, 0.3, 0.3], 139 | }, 140 | { 141 | name: "osc", 142 | params: [60, 0.1, 0], 143 | }, 144 | { 145 | name: "shape", 146 | params: [3, 0.3, 0.01], 147 | }, 148 | { 149 | name: "gradient", 150 | params: [1], 151 | }, 152 | { 153 | name: "src", 154 | params: ["o0"], 155 | }, 156 | { 157 | name: "solid", 158 | params: ["[1, 0, 0]", "[0, 1, 0]", "[0, 0, 1]", 1], 159 | }, 160 | ]; 161 | 162 | export const EXTERNAL_SOURCE_FUNCTIONS = [ 163 | { 164 | name: "initCam", 165 | params: [0], 166 | }, 167 | { 168 | name: "initImage", 169 | params: [ 170 | "https://upload.wikimedia.org/wikipedia/commons/2/25/Hydra-Foto.jpg", 171 | ], 172 | }, 173 | { 174 | name: "initVideo", 175 | params: ["https://media.giphy.com/media/AS9LIFttYzkc0/giphy.mp4"], 176 | }, 177 | // { 178 | // name: "init", 179 | // }, 180 | { 181 | name: "initScreen", 182 | }, 183 | ]; 184 | 185 | export const THREE_FUNCTIONS = [ 186 | // { 187 | // name: "3D", 188 | // params: { 189 | // name: "", 190 | // threeJsContent: "", 191 | // }, 192 | // }, 193 | ]; 194 | 195 | export const GEOMETRY_FUNCTIONS = [ 196 | { 197 | name: "repeat", 198 | params: [3, 3, 0.5, 0], 199 | }, 200 | { 201 | name: "repeatX", 202 | params: [3, 0.5], 203 | }, 204 | { 205 | name: "repeatY", 206 | params: [3, 0.5], 207 | }, 208 | { 209 | name: "scroll", 210 | params: [0.5, 0.5, 0.5, 0], 211 | }, 212 | { 213 | name: "scrollX", 214 | params: [0.5, 0.5], 215 | }, 216 | { 217 | name: "scrollY", 218 | params: [0.5, 0.5], 219 | }, 220 | { 221 | name: "rotate", 222 | params: [10, 1], 223 | }, 224 | { 225 | name: "scale", 226 | params: [1.5, 1, 1, 0.5, 0.5], 227 | }, 228 | { 229 | name: "pixelate", 230 | params: [20, 20], 231 | }, 232 | { 233 | name: "kaleid", 234 | params: [4], 235 | }, 236 | ]; 237 | 238 | export const COLOR_FUNCTIONS = [ 239 | { 240 | name: "r", 241 | params: [1, 0], 242 | }, 243 | { 244 | name: "g", 245 | params: [1, 0], 246 | }, 247 | { 248 | name: "b", 249 | params: [1, 0], 250 | }, 251 | { 252 | name: "color", 253 | params: [1, 0, 1, 1], 254 | }, 255 | { 256 | name: "saturate", 257 | params: [2], 258 | }, 259 | { 260 | name: "hue", 261 | params: [0.4], 262 | }, 263 | { 264 | name: "posterize", 265 | params: [3, 0.6], 266 | }, 267 | { 268 | name: "shift", 269 | params: [0.5, 0, 0, 1], 270 | }, 271 | { 272 | name: "invert", 273 | params: [1], 274 | }, 275 | { 276 | name: "contrast", 277 | params: [1.6], 278 | }, 279 | { 280 | name: "brightness", 281 | params: [0.4], 282 | }, 283 | { 284 | name: "luma", 285 | params: [0.5, 0.1], 286 | }, 287 | { 288 | name: "thresh", 289 | params: [0.5, 0.4], 290 | }, 291 | { 292 | name: "colorama", 293 | params: [0.005], 294 | }, 295 | ]; 296 | 297 | export const MODULATE_FUNCTIONS = [ 298 | { 299 | name: "modulateRepeat", 300 | blocks: [], 301 | params: GEOMETRY_FUNCTIONS[3].params, 302 | }, 303 | { 304 | name: "modulateRepeatX", 305 | blocks: [], 306 | params: GEOMETRY_FUNCTIONS[4].params, 307 | }, 308 | { 309 | name: "modulateRepeatY", 310 | blocks: [], 311 | params: GEOMETRY_FUNCTIONS[5].params, 312 | }, 313 | { 314 | name: "modulateKaleid", 315 | blocks: [], 316 | params: GEOMETRY_FUNCTIONS[9].params, 317 | }, 318 | { 319 | name: "modulateScrollX", 320 | blocks: [], 321 | params: GEOMETRY_FUNCTIONS[7].params, 322 | }, 323 | { 324 | name: "modulateScrollY", 325 | blocks: [], 326 | params: GEOMETRY_FUNCTIONS[8].params, 327 | }, 328 | { 329 | name: "modulate", 330 | blocks: [], 331 | params: [1], 332 | }, 333 | { 334 | name: "modulateScale", 335 | blocks: [], 336 | params: [1, 1], 337 | }, 338 | { 339 | name: "modulatePixelate", 340 | blocks: [], 341 | params: [10, 3], 342 | }, 343 | { 344 | name: "modulateRotate", 345 | blocks: [], 346 | params: [1, 0], 347 | }, 348 | { 349 | name: "modulateHue", 350 | blocks: [], 351 | params: [1], 352 | }, 353 | ]; 354 | 355 | export const BLEND_FUNCTIONS = [ 356 | { 357 | name: "add", 358 | blocks: [], 359 | params: [1], 360 | }, 361 | { 362 | name: "sub", 363 | blocks: [], 364 | params: [1], 365 | }, 366 | { 367 | name: "layer", 368 | blocks: [], 369 | }, 370 | { 371 | name: "blend", 372 | blocks: [], 373 | params: [0.5], 374 | }, 375 | { 376 | name: "mult", 377 | blocks: [], 378 | params: [1], 379 | }, 380 | { 381 | name: "diff", 382 | blocks: [], 383 | }, 384 | { 385 | name: "mask", 386 | blocks: [], 387 | }, 388 | ]; 389 | -------------------------------------------------------------------------------- /src/components/NestedDraggable.vue: -------------------------------------------------------------------------------- 1 | 94 | 95 | 231 | 232 | 387 | -------------------------------------------------------------------------------- /src/pages/GuiPage.vue: -------------------------------------------------------------------------------- 1 | 300 | 301 | 383 | 384 | 417 | -------------------------------------------------------------------------------- /src/components/ParentBlock.vue: -------------------------------------------------------------------------------- 1 | 142 | 143 | 150 | 151 | 341 | 342 | 480 | -------------------------------------------------------------------------------- /src/stores/hydra.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { ref, computed, reactive } from "vue"; 3 | import { useBroadcastChannel } from "@vueuse/core"; 4 | import { deepCopy, flatten, flattenExternal } from "@/utils/object-utils"; 5 | import { 6 | setSafeLocalStorage, 7 | getSafeLocalStorage, 8 | showErrorToast, 9 | setHueLights, 10 | } from "@/utils"; 11 | import { 12 | INITIAL_BLOCKS, 13 | MAX_NUMBER_OF_SOURCES, 14 | MAX_NUMBER_OF_EXTERNALS, 15 | TYPE_SRC, 16 | TYPE_THREE, 17 | TYPE_EXTERNAL, 18 | TYPE_SIMPLE, 19 | TYPE_COMPLEX, 20 | DEFAULT_POSITION, 21 | } from "@/constants"; 22 | 23 | export const useHydraStore = defineStore("hydra", () => { 24 | // State 25 | const r = ref(0); 26 | const g = ref(0); 27 | const b = ref(0); 28 | const focused = ref(null); 29 | const focusedParent = ref(null); 30 | const isInputFocused = ref(false); 31 | const blocks = ref([]); 32 | const externalSourceBlocks = ref([]); 33 | const synthSettings = reactive({ 34 | output: 0, 35 | bpm: 120, 36 | speed: 1, 37 | resolution: 100, 38 | fps: 60, 39 | }); 40 | const codeString = ref(""); 41 | const history = ref([]); 42 | const historyIndex = ref(0); 43 | const canUndo = computed(() => historyIndex.value < history.value.length - 1); 44 | const canRedo = computed(() => historyIndex.value > 0); 45 | const copied = ref(null); 46 | const copiedParent = ref(null); 47 | const isCut = ref(false); 48 | 49 | // Scenes state 50 | const scenes = ref([]); 51 | const currentSceneId = ref(null); 52 | 53 | const { post } = useBroadcastChannel({ name: "hydra-plus-channel" }); 54 | 55 | // Actions 56 | const updateRGB = ({ red, green, blue }) => { 57 | r.value = red; 58 | g.value = green; 59 | b.value = blue; 60 | update({ shouldSetHistory: false }); 61 | }; 62 | 63 | const setFocus = (focus, parent) => { 64 | // @todo merge the two states into one 65 | focused.value = focus; 66 | focusedParent.value = parent; 67 | }; 68 | 69 | const setInputFocus = (isFocused) => { 70 | // console.log("inputFocus", isFocused); 71 | isInputFocused.value = isFocused; 72 | }; 73 | 74 | const addParent = (source, shouldSetHistory = true, isPasting = false) => { 75 | const copiedSource = deepCopy(source); 76 | 77 | if ( 78 | source.type === TYPE_SRC && 79 | blocks.value.length >= MAX_NUMBER_OF_SOURCES 80 | ) { 81 | showErrorToast( 82 | `You can't add more than ${MAX_NUMBER_OF_SOURCES} sources.`, 83 | ); 84 | return false; 85 | } 86 | 87 | if ( 88 | (source.type === TYPE_EXTERNAL || source.type === TYPE_THREE) && 89 | externalSourceBlocks.value.length >= MAX_NUMBER_OF_EXTERNALS 90 | ) { 91 | showErrorToast( 92 | `You can't add more than ${MAX_NUMBER_OF_EXTERNALS} externals.`, 93 | ); 94 | return false; 95 | } 96 | 97 | if (isPasting && !canPasteParent.value) { 98 | showErrorToast( 99 | `Can't paste here: inserting "${copiedSource.name}" into "${focused.value?.name}".`, 100 | ); 101 | return false; 102 | } 103 | 104 | // Calculate the highest z-index among all blocks for the new block 105 | const allBlocks = [...blocks.value, ...externalSourceBlocks.value]; 106 | const maxZIndex = Math.max( 107 | 0, 108 | ...allBlocks.map((block) => block.zIndex || 0), 109 | ); 110 | 111 | // Assign a rotating color ID based on existing blocks 112 | const existingColorIds = allBlocks.map((block) => block.colorId || 0); 113 | const maxColorId = Math.max(0, ...existingColorIds); 114 | const nextColorId = maxColorId + 1; 115 | 116 | const newBlock = { 117 | ...copiedSource, 118 | position: window.contextMenuPosition 119 | ? { 120 | x: window.contextMenuPosition.x - 180, 121 | y: window.contextMenuPosition.y - 20, 122 | } 123 | : DEFAULT_POSITION, 124 | zIndex: maxZIndex + 1, 125 | colorId: nextColorId, 126 | }; 127 | 128 | window.contextMenuPosition = null; 129 | 130 | if (source.type === TYPE_SRC) { 131 | blocks.value.push(newBlock); 132 | } else { 133 | externalSourceBlocks.value.push(newBlock); 134 | } 135 | 136 | setFocus(newBlock); 137 | 138 | synthSettings.output = blocks.value.length - 1; 139 | 140 | if (source.type === TYPE_EXTERNAL) { 141 | const addedExternal = flattenExternal( 142 | copiedSource, 143 | externalSourceBlocks.value.length - 1, 144 | ); 145 | window.eval(addedExternal); 146 | post(addedExternal); 147 | } else { 148 | setBlocks({ 149 | blocks: [...blocks.value, ...externalSourceBlocks.value], 150 | shouldSetHistory, 151 | }); 152 | } 153 | 154 | return true; 155 | }; 156 | 157 | const addChild = (effect, shouldSetHistory = true, isPasting = false) => { 158 | if (!isPasting || canPasteChild.value) { 159 | focused.value.blocks.push(deepCopy(effect)); 160 | } else if (focusedParent.value && canPasteChildToParent.value) { 161 | focusedParent.value.blocks.push(deepCopy(effect)); 162 | } else if (!focusedParent.value) { 163 | return addParent(effect, shouldSetHistory, isPasting); 164 | } else { 165 | showErrorToast( 166 | `Can't paste here: inserting "${copied.value.name}" into "${focused.value.name}".`, 167 | ); 168 | return false; 169 | } 170 | 171 | if (effect.type === TYPE_COMPLEX && effect.blocks.length === 0) { 172 | setFocus(focused.value.blocks[focused.value.blocks.length - 1]); 173 | } 174 | 175 | setBlocks({ 176 | blocks: [...blocks.value, ...externalSourceBlocks.value], 177 | shouldSetHistory, 178 | }); 179 | 180 | return true; 181 | }; 182 | 183 | const setBlocks = ({ blocks: newBlocks, shouldSetHistory = true }) => { 184 | const newSrcBlocks = newBlocks.filter((block) => block.type === TYPE_SRC); 185 | const newExternalBlocks = newBlocks.filter((block) => 186 | [TYPE_EXTERNAL, TYPE_THREE].includes(block.type), 187 | ); 188 | 189 | blocks.value = newSrcBlocks; 190 | externalSourceBlocks.value = newExternalBlocks; 191 | update({ shouldSetHistory }); 192 | }; 193 | 194 | const loadSceneData = (sceneData) => { 195 | const { 196 | blocks: sceneBlocks = [], 197 | externalSourceBlocks: sceneExternalBlocks = [], 198 | synthSettings: sceneSynthSettings, 199 | } = sceneData; 200 | 201 | blocks.value.length = 0; 202 | externalSourceBlocks.value.length = 0; 203 | 204 | history.value.length = 0; 205 | historyIndex.value = 0; 206 | 207 | blocks.value.push(...sceneBlocks); 208 | externalSourceBlocks.value.push(...sceneExternalBlocks); 209 | 210 | if (sceneSynthSettings) { 211 | Object.assign(synthSettings, sceneSynthSettings); 212 | } 213 | 214 | update(); 215 | }; 216 | 217 | const setBlockPosition = ({ index, type, position }) => { 218 | let blockPosition; 219 | 220 | if (type === TYPE_SRC) { 221 | blockPosition = blocks.value[index].position; 222 | blocks.value[index].position = position; 223 | } else { 224 | blockPosition = externalSourceBlocks.value[index].position; 225 | externalSourceBlocks.value[index].position = position; 226 | } 227 | 228 | // If the block hasn't moved, don't set history 229 | if ( 230 | Math.abs(blockPosition.x - position.x) < 50 || 231 | Math.abs(blockPosition.y - position.y) < 50 232 | ) { 233 | return; 234 | } 235 | 236 | setHistory(); 237 | }; 238 | 239 | const setBlockZIndex = ({ index, type, zIndex }) => { 240 | if (type === TYPE_SRC) { 241 | blocks.value[index].zIndex = zIndex; 242 | } else { 243 | externalSourceBlocks.value[index].zIndex = zIndex; 244 | } 245 | }; 246 | 247 | const deleteParent = ({ type, index }) => { 248 | if (type === TYPE_SRC) { 249 | blocks.value.splice(index, 1); 250 | } else { 251 | externalSourceBlocks.value.splice(index, 1); 252 | } 253 | 254 | if (!blocks.value[synthSettings.output] && blocks.value.length > 0) { 255 | synthSettings.output = blocks.value.length - 1; 256 | } 257 | 258 | setBlocks({ 259 | blocks: [...blocks.value, ...externalSourceBlocks.value], 260 | shouldSetHistory: true, 261 | isDelete: true, 262 | }); 263 | }; 264 | 265 | const deleteChild = ({ element, parent }, ignoreFocus) => { 266 | const stack = parent.blocks ? [parent.blocks] : []; 267 | 268 | while (stack.length) { 269 | const currentArray = stack.pop(); 270 | for (let i = currentArray.length - 1; i >= 0; i--) { 271 | if (currentArray[i] === element) { 272 | if (!ignoreFocus) setFocus(parent); 273 | 274 | currentArray.splice(i, 1); 275 | 276 | setBlocks({ 277 | blocks: [...blocks.value, ...externalSourceBlocks.value], 278 | }); 279 | 280 | return; 281 | } 282 | 283 | if (currentArray[i].blocks) { 284 | stack.push(currentArray[i].blocks); 285 | } 286 | } 287 | } 288 | }; 289 | 290 | const update = ( 291 | { shouldSetHistory } = { 292 | shouldSetHistory: true, 293 | }, 294 | ) => { 295 | const isHuePluginEnabled = false && process.env.NODE_ENV !== "production"; 296 | let newCodeString = ""; 297 | 298 | if (blocks.value.length === 0) { 299 | newCodeString = "hush()"; 300 | } else { 301 | if (!blocks.value[synthSettings.output]) { 302 | synthSettings.output = 0; 303 | } 304 | 305 | for (const [i, block] of externalSourceBlocks.value.entries()) { 306 | if (block.type !== TYPE_THREE && block.name !== "initScreen") { 307 | newCodeString += flattenExternal(block, i); 308 | } 309 | } 310 | 311 | if (isHuePluginEnabled) { 312 | setHueLights({ r: r.value, g: g.value, b: b.value }); 313 | } 314 | 315 | for (const [i, block] of blocks.value.entries()) { 316 | newCodeString += `${flatten(block)}`; 317 | 318 | if (isHuePluginEnabled) { 319 | newCodeString += `.color(${r.value}, ${g.value}, ${b.value}, 1)`; 320 | } 321 | 322 | newCodeString += `.out(o${i})\n`; 323 | } 324 | 325 | newCodeString += `render(o${synthSettings.output})`; 326 | } 327 | 328 | document.getElementById("hydra-canvas"); 329 | 330 | try { 331 | window.eval(newCodeString); 332 | codeString.value = newCodeString; 333 | } catch (error) { 334 | showErrorToast(error); 335 | } 336 | 337 | if (shouldSetHistory) setHistory(); 338 | }; 339 | 340 | const send = () => { 341 | if (codeString.value) { 342 | post(codeString.value); 343 | 344 | updateCurrentScene({ 345 | blocks: blocks.value, 346 | externalSourceBlocks: externalSourceBlocks.value, 347 | synthSettings: synthSettings, 348 | }); 349 | 350 | saveScenes(); 351 | } 352 | }; 353 | 354 | const setSynthSettings = (settings) => { 355 | window.eval(`bpm = ${settings.bpm}`); 356 | post(`bpm = ${settings.bpm}`); 357 | window.eval(`speed = ${settings.speed}`); 358 | post(`speed = ${settings.speed}`); 359 | 360 | const multiplier = (settings.resolution * window.devicePixelRatio) / 100; 361 | const width = window.outerWidth * multiplier; 362 | const height = window.outerHeight * multiplier; 363 | const resolutionString = `setResolution(${height}, ${width})`; 364 | 365 | window.eval(resolutionString); 366 | post(resolutionString); 367 | 368 | window.eval(`fps = ${settings.fps}`); 369 | post(`fps = ${settings.fps}`); 370 | 371 | Object.assign(synthSettings, settings); 372 | 373 | updateCurrentScene({ 374 | synthSettings: synthSettings, 375 | }); 376 | 377 | saveScenes(); 378 | }; 379 | 380 | const setOutput = (output) => { 381 | if (synthSettings.output === output) return; 382 | 383 | synthSettings.output = output; 384 | update({ shouldSetHistory: false }); 385 | }; 386 | 387 | // History 388 | 389 | const setHistory = () => { 390 | if (history.value.length > 99) history.value.pop(); 391 | 392 | history.value.splice(0, historyIndex.value); 393 | history.value.unshift( 394 | deepCopy({ 395 | blocks: blocks.value, 396 | externalSourceBlocks: externalSourceBlocks.value, 397 | }), 398 | ); 399 | 400 | historyIndex.value = 0; 401 | 402 | // console.log(history.value[historyIndex.value]?.blocks); 403 | }; 404 | 405 | /** 406 | * @param {Number} direction 1 for undo, -1 for redo 407 | */ 408 | const undoRedo = (direction) => { 409 | const newIndex = historyIndex.value + direction; 410 | if (newIndex < 0 || newIndex >= history.value.length) return; 411 | 412 | historyIndex.value = newIndex; 413 | const historyState = history.value[newIndex]; 414 | 415 | setBlocks({ 416 | blocks: deepCopy([ 417 | ...historyState.blocks, 418 | ...historyState.externalSourceBlocks, 419 | ]), 420 | shouldSetHistory: false, 421 | }); 422 | 423 | // console.log(history.value[historyIndex.value]?.blocks); 424 | }; 425 | 426 | // Copy & paste 427 | 428 | const copyBlock = (cutting = false) => { 429 | if (!focused.value) return; 430 | 431 | copied.value = focused.value; 432 | copiedParent.value = focusedParent.value; 433 | 434 | isCut.value = cutting; 435 | }; 436 | 437 | const resetCut = () => { 438 | copiedParent.value = null; 439 | isCut.value = false; 440 | }; 441 | 442 | const pasteBlock = () => { 443 | if (!copied.value) return; 444 | 445 | if (copied.value?.position) { 446 | // Parent block is pasted 447 | const pasted = performPaste(); 448 | 449 | if (pasted && isCut.value) { 450 | const copiedIndex = blocks.value.findIndex( 451 | (block) => block === copied.value, 452 | ); 453 | 454 | deleteParent({ type: copied.value.type, index: copiedIndex }); 455 | 456 | resetCut(); 457 | } 458 | } else { 459 | // Child block is pasted 460 | const pasted = performPaste(); 461 | 462 | if (pasted && isCut.value) { 463 | deleteChild( 464 | { 465 | element: copied.value, 466 | parent: copiedParent.value, 467 | }, 468 | isCut.value, 469 | ); 470 | 471 | resetCut(); 472 | } 473 | } 474 | }; 475 | 476 | const canPasteChildToTarget = (target) => 477 | (target && 478 | target !== copied.value && 479 | target.type === TYPE_SRC && 480 | (copied.value?.type === TYPE_SIMPLE || 481 | copied.value?.type === TYPE_COMPLEX)) || 482 | (target && 483 | target.type === TYPE_COMPLEX && 484 | copied.value?.type === TYPE_SRC && 485 | target.blocks.length === 0); 486 | 487 | const canPasteChild = computed(() => canPasteChildToTarget(focused.value)); 488 | 489 | const canPasteChildToParent = computed(() => 490 | canPasteChildToTarget(focusedParent.value), 491 | ); 492 | 493 | const canPasteParent = computed(() => copied.value?.type === TYPE_SRC); 494 | 495 | const canPaste = computed(() => canPasteChild.value || canPasteParent.value); 496 | 497 | const performPaste = () => { 498 | if (focused.value) { 499 | return addChild(deepCopy(copied.value), !isCut.value, true); 500 | } else { 501 | return addParent(deepCopy(copied.value), !isCut.value, true); 502 | } 503 | }; 504 | 505 | const initializeScenes = () => { 506 | const savedScenes = getSafeLocalStorage("scenes"); 507 | const savedCurrentSceneId = getSafeLocalStorage("currentSceneId"); 508 | 509 | if (savedScenes) { 510 | scenes.value = savedScenes; 511 | // Load the saved current scene ID if it exists and is valid 512 | if ( 513 | savedCurrentSceneId && 514 | scenes.value.some((scene) => scene.id === savedCurrentSceneId) 515 | ) { 516 | currentSceneId.value = savedCurrentSceneId; 517 | } else if (scenes.value.length > 0) { 518 | // Fallback to first scene if saved scene ID is invalid 519 | currentSceneId.value = scenes.value[0].id; 520 | } 521 | } else { 522 | // Check for legacy data first 523 | const legacyBlocks = getSafeLocalStorage("blocks"); 524 | const legacyExternalBlocks = getSafeLocalStorage("externalSourceBlocks"); 525 | const legacySynthSettings = getSafeLocalStorage("synthSettings"); 526 | 527 | if (legacyBlocks || legacyExternalBlocks || legacySynthSettings) { 528 | convertLegacyData(); 529 | } else { 530 | // No saved data, create default scene with initial blocks 531 | const defaultScene = { 532 | id: generateSceneId(), 533 | name: "Scene 1", 534 | blocks: INITIAL_BLOCKS, 535 | externalSourceBlocks: [], 536 | synthSettings: { 537 | output: 0, 538 | bpm: 120, 539 | speed: 1, 540 | resolution: 100, 541 | fps: 60, 542 | }, 543 | }; 544 | 545 | scenes.value = [defaultScene]; 546 | currentSceneId.value = defaultScene.id; 547 | 548 | saveScenes(); 549 | 550 | update(); 551 | } 552 | } 553 | }; 554 | 555 | const convertLegacyData = () => { 556 | const legacyBlocks = getSafeLocalStorage("blocks") || []; 557 | const legacyExternalBlocks = 558 | getSafeLocalStorage("externalSourceBlocks") || []; 559 | const legacySynthSettings = getSafeLocalStorage("synthSettings"); 560 | 561 | const defaultScene = { 562 | id: generateSceneId(), 563 | name: "Scene 1", 564 | blocks: legacyBlocks, 565 | externalSourceBlocks: legacyExternalBlocks, 566 | synthSettings: legacySynthSettings, 567 | }; 568 | 569 | scenes.value = [defaultScene]; 570 | currentSceneId.value = defaultScene.id; 571 | 572 | saveScenes(); 573 | }; 574 | 575 | const generateSceneId = () => { 576 | return `scene_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 577 | }; 578 | 579 | const createScene = (name = null) => { 580 | let sceneName = null; 581 | 582 | if (name) { 583 | sceneName = name; 584 | } else { 585 | sceneName = `Scene ${scenes.value.length + 1}`; 586 | 587 | let i = 2; 588 | while (scenes.value.some((scene) => scene.name === sceneName)) { 589 | sceneName = `Scene ${scenes.value.length + 1} (${i})`; 590 | i++; 591 | } 592 | } 593 | 594 | const newScene = { 595 | id: generateSceneId(), 596 | name: sceneName, 597 | blocks: [], 598 | externalSourceBlocks: [], 599 | synthSettings: { 600 | output: 0, 601 | bpm: 120, 602 | speed: 1, 603 | resolution: 100, 604 | fps: 60, 605 | }, 606 | }; 607 | 608 | scenes.value.push(newScene); 609 | // Set the new scene as current and save the current scene ID 610 | currentSceneId.value = newScene.id; 611 | setSafeLocalStorage("currentSceneId", newScene.id); 612 | 613 | // Clear the UI blocks to start with a blank scene 614 | blocks.value.length = 0; 615 | externalSourceBlocks.value.length = 0; 616 | 617 | // Reset synth settings to defaults 618 | Object.assign(synthSettings, newScene.synthSettings); 619 | 620 | update({ shouldSetHistory: false }); 621 | 622 | return newScene; 623 | }; 624 | 625 | const deleteScene = (sceneId) => { 626 | const sceneIndex = scenes.value.findIndex((scene) => scene.id === sceneId); 627 | if (sceneIndex === -1) return false; 628 | 629 | // Don't allow deleting the last scene 630 | if (scenes.value.length === 1) { 631 | return false; 632 | } 633 | 634 | const wasCurrentScene = currentSceneId.value === sceneId; 635 | 636 | scenes.value.splice(sceneIndex, 1); 637 | 638 | if (wasCurrentScene && scenes.value.length > 0) { 639 | const newCurrentSceneId = scenes.value[0].id; 640 | currentSceneId.value = newCurrentSceneId; 641 | // Save the new current scene ID 642 | setSafeLocalStorage("currentSceneId", newCurrentSceneId); 643 | 644 | // Load the new current scene's data 645 | const newCurrentScene = scenes.value[0]; 646 | if (newCurrentScene) { 647 | loadSceneData({ 648 | blocks: newCurrentScene.blocks || [], 649 | externalSourceBlocks: newCurrentScene.externalSourceBlocks || [], 650 | synthSettings: newCurrentScene.synthSettings, 651 | }); 652 | } 653 | } 654 | 655 | return true; 656 | }; 657 | 658 | const switchToScene = (sceneId) => { 659 | const scene = scenes.value.find((scene) => scene.id === sceneId); 660 | if (scene) { 661 | currentSceneId.value = sceneId; 662 | setSafeLocalStorage("currentSceneId", sceneId); 663 | return scene; 664 | } 665 | return null; 666 | }; 667 | 668 | const currentScene = computed(() => { 669 | return ( 670 | scenes.value.find((scene) => scene.id === currentSceneId.value) || null 671 | ); 672 | }); 673 | 674 | const updateCurrentScene = (data) => { 675 | const scene = currentScene.value; 676 | if (!scene) return; 677 | 678 | Object.assign(scene, data); 679 | }; 680 | 681 | const saveScenes = () => { 682 | setSafeLocalStorage("scenes", scenes.value); 683 | }; 684 | 685 | const renameScene = (sceneId, newName) => { 686 | const scene = scenes.value.find((scene) => scene.id === sceneId); 687 | if (scene) { 688 | scene.name = newName; 689 | } 690 | }; 691 | 692 | return { 693 | // State 694 | r, 695 | g, 696 | b, 697 | focused, 698 | isInputFocused, 699 | blocks, 700 | externalSourceBlocks, 701 | codeString, 702 | synthSettings, 703 | history, 704 | historyIndex, 705 | canUndo, 706 | canRedo, 707 | copied, 708 | canPaste, 709 | scenes, 710 | currentSceneId, 711 | currentScene, 712 | 713 | // Actions 714 | updateRGB, 715 | setFocus, 716 | setInputFocus, 717 | addParent, 718 | addChild, 719 | setBlocks, 720 | loadSceneData, 721 | setBlockPosition, 722 | setBlockZIndex, 723 | deleteParent, 724 | deleteChild, 725 | update, 726 | send, 727 | setSynthSettings, 728 | setOutput, 729 | setHistory, 730 | undoRedo, 731 | copyBlock, 732 | pasteBlock, 733 | 734 | // Scene management 735 | initializeScenes, 736 | createScene, 737 | deleteScene, 738 | switchToScene, 739 | updateCurrentScene, 740 | renameScene, 741 | saveScenes, 742 | }; 743 | }); 744 | --------------------------------------------------------------------------------