├── .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 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarGroup.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/components/ui/select/SelectItemText.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
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 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarShortcut.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
15 |
16 |
17 |
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 |
10 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/components/ui/select/SelectValue.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuPortal.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
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 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarSub.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/pages/VisualizerPage.vue:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/src/components/ui/number-field/NumberFieldContent.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenu.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuSub.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuTrigger.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/components/ui/select/SelectLabel.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarLabel.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuRadioGroup.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/components/ui/select/SelectSeparator.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
24 |
25 |
--------------------------------------------------------------------------------
/src/components/ui/button/Button.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuSeparator.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
24 |
25 |
--------------------------------------------------------------------------------
/src/components/ui/number-field/NumberFieldInput.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
20 |
21 |
--------------------------------------------------------------------------------
/src/components/ui/toast/ToastTitle.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/components/ui/toast/ToastDescription.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
24 |
25 |
26 |
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 |
22 |
26 |
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 |
21 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/components/ModalRenderer.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
35 |
36 |
--------------------------------------------------------------------------------
/src/components/ui/select/Select.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuLabel.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/components/ui/toast/ToastViewport.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
31 |
32 |
--------------------------------------------------------------------------------
/src/components/ui/select/SelectScrollUpButton.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/components/ui/select/SelectScrollDownButton.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/Menubar.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
34 |
35 |
36 |
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 |
20 |
29 |
30 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarTrigger.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/components/ui/toast/Toaster.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{ toast.title }}
22 |
23 |
24 |
25 |
26 |
27 |
28 | {{ toast.description }}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
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 |
24 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/components/ui/number-field/NumberFieldIncrement.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/components/ui/toast/ToastClose.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
30 |
31 |
32 |
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 |
21 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarItem.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuItem.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarSubTrigger.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/components/modal/ThreeModal.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 | No 3D blocks in the scene
30 |
31 | Close
32 |
33 |
34 |
35 |
36 |
37 | s{{ index }}
38 |
39 |
40 |
41 |
42 |
cancel
43 |
save
44 |
45 |
46 |
47 |
48 |
58 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuSubTrigger.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/components/ui/number-field/NumberField.vue:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/components/ui/select/SelectTrigger.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
33 |
34 |
35 |
36 |
37 |
38 |
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 | 
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 |
41 |
46 |
47 |
48 |
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 |
31 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarRadioItem.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarCheckboxItem.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuRadioItem.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
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 |
31 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
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 |
31 |
40 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuContent.vue:
--------------------------------------------------------------------------------
1 |
40 |
41 |
42 |
43 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/src/components/ui/menubar/MenubarContent.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
37 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/components/ui/context-menu/ContextMenuSubContent.vue:
--------------------------------------------------------------------------------
1 |
41 |
42 |
43 |
52 |
53 |
54 |
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 |
48 |
49 |
58 |
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/components/ui/slider/Slider.vue:
--------------------------------------------------------------------------------
1 |
38 |
39 |
40 |
49 |
52 |
55 |
56 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/src/components/modal/WelcomeModal.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
welcome to version 0.9.2!
16 |
17 |
~ Introducing Scenes!
18 |
19 |
20 | You can now save your scenes and switch between them. This will make it
21 | easier to manage your projects.
22 |
23 |
24 | You can also collapse blocks by clicking on the
25 | "arrow"
26 | icon on top of your source blocks.
27 |
28 |
29 | I tried to fix some bugs I was aware of, please let me know if you find
30 | any issues. Other features are on the way, keep an eye out for more
31 | updates! ~
32 |
33 |
34 |
35 |
36 |
37 |
welcome!
38 |
39 | hydra+ is a graphical user interface for
40 | hydra , a
41 | javascript library for livecoding visuals.
42 |
43 |
44 | Please refer to the
45 | hydra api for
46 | information on how to use the synthatizer.
47 |
48 |
49 |
50 |
51 | Thank you for using hydra+, your feedback, shared via
52 |
53 | Github
54 |
55 | is highly appreciated. I'll try to answer any questions you might have.
56 |
57 |
58 |
59 |
60 | ❤️🔥 happy hacking!
61 |
62 |
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 |
80 |
81 |
82 |
83 | Enter a new name for "{{ scene?.name }}"
84 |
91 |
92 |
93 |
94 | Cancel
95 |
96 | Rename Scene
97 |
98 |
99 |
100 |
101 |
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 |
52 |
53 |
64 |
65 |
74 |
75 |
76 |
77 |
78 |
79 |
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 |
90 |
91 |
92 |
93 |
94 |
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 |
44 |
45 |
46 |
Resolution
47 |
48 |
49 |
57 | Speed
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | BPM
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | FPS
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
90 | Enable animations
91 |
92 |
93 |
download hydra code
94 |
95 |
save
96 |
97 |
98 |
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 |
112 |
113 |
119 |
120 | {{ functionBlock.name }}
121 |
122 |
123 |
124 |
125 | {{ fn.name }}
126 |
127 |
128 |
129 |
130 |
131 |
132 |
173 |
--------------------------------------------------------------------------------
/src/components/modal/BaseModal.vue:
--------------------------------------------------------------------------------
1 |
66 |
67 |
68 |
69 |
e.stopPropagation()">
70 |
74 |
75 |
76 |
77 |
78 |
79 | Let's go
80 |
81 |
82 |
83 |
84 |
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 |
160 |
163 |
164 |
165 |
166 | {{ store.currentScene?.name || "Select Scene" }}
167 |
168 |
169 |
175 | {{ scene.name }}
176 |
177 |
182 |
183 |
184 |
190 |
191 |
192 |
193 |
194 |
195 |
199 | + New Scene
200 |
201 |
202 |
203 |
204 |
205 | New
206 |
207 | Source
208 |
212 | Effect
213 |
214 |
215 |
216 |
217 |
218 | Edit
219 |
220 |
221 | Undo
222 | {{ MODIFIER_KEY }}Z
223 |
224 |
225 | Redo
226 | {{ MODIFIER_KEY }}Y
227 |
228 |
229 |
230 |
231 |
235 | Cut
236 | {{ MODIFIER_KEY }}X
237 |
238 |
242 | Copy
243 | {{ MODIFIER_KEY }}C
244 |
245 |
246 | Paste
247 | {{ MODIFIER_KEY }}V
248 |
249 |
250 |
251 |
252 |
253 | View
254 |
255 | Visualizer
256 | Settings
257 |
258 |
259 | Toggle Fullscreen
260 | Esc
261 |
262 |
263 |
264 |
265 |
266 |
271 | Send
272 |
273 |
274 |
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 |
96 | handleMove(e)"
115 | @end="handleEnd"
116 | >
117 |
118 |
122 |
123 |
124 |
125 |
126 |
130 | {{ element.name }}
131 |
132 |
136 |
137 |
138 |
143 |
147 |
148 |
149 |
150 |
151 |
156 | s{{ sIndex }} - {{ source.name }}
157 |
158 |
163 | o{{ oIndex }} - {{ output.name }}
164 |
165 |
166 |
167 |
168 |
169 |
176 |
180 | {{ PARAM_MAPPINGS[element.name][paramIndex] }}
181 |
182 |
189 |
190 |
191 |
192 |
193 |
197 | Add source
198 |
199 |
203 | Add effect
204 |
205 |
206 | Cut
207 | Copy
208 |
212 | Paste
213 |
214 |
215 | Delete
216 |
217 |
218 |
219 |
220 |
227 |
228 |
229 |
230 |
231 |
232 |
387 |
--------------------------------------------------------------------------------
/src/pages/GuiPage.vue:
--------------------------------------------------------------------------------
1 |
300 |
301 |
302 |
303 |
304 |
309 |
313 |
317 |
321 |
322 |
323 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |
334 |
335 | New source
336 |
337 |
341 | Hide UI
342 | Esc
343 |
344 |
345 |
346 | Paste
347 |
348 |
349 |
350 |
351 |
352 |
358 |
359 |
360 |
380 |
381 |
382 |
383 |
384 |
417 |
--------------------------------------------------------------------------------
/src/components/ParentBlock.vue:
--------------------------------------------------------------------------------
1 |
142 |
143 |
150 |
151 |
152 |
153 |
154 |
163 |
202 |
203 |
204 |
205 |
210 |
211 |
216 | {{ PARAM_MAPPINGS[block.name][paramIndex] }}
217 |
218 |
219 |
225 |
226 |
227 | {{ cameraNames[block.params[paramIndex]] }}
228 |
229 |
230 |
231 |
232 |
237 | {{ name }}
238 |
239 |
240 |
241 |
242 |
248 |
249 |
250 |
251 |
252 |
253 |
258 | s{{ sIndex }} - {{ source.name }}
259 |
260 |
265 | o{{ oIndex }} - {{ output.name }}
266 |
267 |
268 |
269 |
270 |
278 |
279 |
280 |
281 |
286 |
287 |
295 |
296 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
318 |
319 |
320 |
321 |
322 |
326 | Set active output
327 |
328 |
329 |
330 | New effect
331 |
332 |
333 | Cut
334 | Copy
335 | Paste
336 |
337 | Delete
338 |
339 |
340 |
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 |
--------------------------------------------------------------------------------