├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.json ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── README.md ├── angular.json ├── eslint.config.mjs ├── nodemon.json ├── package.json ├── public ├── 404.html ├── assets │ └── prettier │ │ ├── parser-babel.js │ │ ├── parser-html.js │ │ ├── parser-postcss.js │ │ ├── parser-typescript.js │ │ └── standalone.js ├── favicon.ico ├── logo.jpg ├── proxy-console │ ├── index.html │ └── index.js └── templates │ ├── angular.zip │ ├── eslint-prettier.zip │ ├── next.zip │ ├── node.zip │ ├── react-ts.zip │ ├── solid-ts.zip │ ├── standard.zip │ ├── static.zip │ ├── vanilla.zip │ └── vue3-ts.zip ├── screenshots ├── screenshot-2.png ├── screenshot-3.png └── screenshot.png ├── scripts ├── genProxyScript.js └── zip.js ├── src ├── app │ ├── _shared │ │ ├── components │ │ │ ├── confirm-dialog │ │ │ │ └── confirm-dialog.ts │ │ │ ├── github-token-dialog │ │ │ │ ├── github-token-dialog.component.html │ │ │ │ ├── github-token-dialog.component.scss │ │ │ │ └── github-token-dialog.component.ts │ │ │ ├── github-url-dialog │ │ │ │ ├── github-url-dialog.component.html │ │ │ │ ├── github-url-dialog.component.scss │ │ │ │ └── github-url-dialog.component.ts │ │ │ └── template-modal │ │ │ │ ├── config.ts │ │ │ │ ├── template-modal.component.html │ │ │ │ ├── template-modal.component.scss │ │ │ │ └── template-modal.component.ts │ │ ├── constant.ts │ │ ├── service │ │ │ ├── Emitter.ts │ │ │ ├── device-guard.ts │ │ │ ├── device.service.ts │ │ │ ├── gist.service.ts │ │ │ ├── local-storage.service.ts │ │ │ └── redirect-guard.ts │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ │ └── index.ts │ ├── app.component.ts │ ├── app.config.ts │ ├── editor │ │ ├── components │ │ │ └── shortcut-dialog.component.ts │ │ ├── constants │ │ │ └── index.ts │ │ ├── editor.component.ts │ │ ├── features │ │ │ ├── footer │ │ │ │ ├── footer.component.html │ │ │ │ ├── footer.component.scss │ │ │ │ └── footer.component.ts │ │ │ ├── header │ │ │ │ ├── header.component.html │ │ │ │ ├── header.component.scss │ │ │ │ └── header.component.ts │ │ │ └── main │ │ │ │ ├── components │ │ │ │ └── resizer │ │ │ │ │ ├── resize-container.scss │ │ │ │ │ ├── resize-container.ts │ │ │ │ │ ├── resize-manager.service.ts │ │ │ │ │ ├── resizer.scss │ │ │ │ │ └── resizer.ts │ │ │ │ ├── edit │ │ │ │ ├── code-editor │ │ │ │ │ ├── code-editor.component.scss │ │ │ │ │ ├── code-editor.component.ts │ │ │ │ │ ├── code-editor.service.ts │ │ │ │ │ ├── deps-parsre.service.ts │ │ │ │ │ ├── prettier.service.ts │ │ │ │ │ └── type-loader.service.ts │ │ │ │ ├── edit.component.html │ │ │ │ ├── edit.component.scss │ │ │ │ ├── edit.component.ts │ │ │ │ └── edit.service.ts │ │ │ │ ├── main.component.html │ │ │ │ ├── main.component.scss │ │ │ │ ├── main.component.ts │ │ │ │ ├── main.service.ts │ │ │ │ ├── ouput │ │ │ │ ├── console │ │ │ │ │ ├── _console-script.js │ │ │ │ │ ├── _console-script.ts │ │ │ │ │ ├── compound-obj-renderer.html │ │ │ │ │ ├── compound-obj-renderer.scss │ │ │ │ │ ├── compound-obj-renderer.ts │ │ │ │ │ ├── console.component.html │ │ │ │ │ ├── console.component.scss │ │ │ │ │ ├── console.component.ts │ │ │ │ │ ├── console.service.ts │ │ │ │ │ ├── getProxyConsoleScript.ts │ │ │ │ │ └── primitive-renderer.component.ts │ │ │ │ ├── preview │ │ │ │ │ ├── preview.component.html │ │ │ │ │ ├── preview.component.scss │ │ │ │ │ └── preview.component.ts │ │ │ │ └── terminal │ │ │ │ │ ├── terminal.component.scss │ │ │ │ │ ├── terminal.component.ts │ │ │ │ │ └── terminal.service.ts │ │ │ │ └── sidebar │ │ │ │ ├── fileTree │ │ │ │ ├── file-tree.component.html │ │ │ │ ├── file-tree.component.scss │ │ │ │ └── file-tree.component.ts │ │ │ │ ├── sidebar.component.html │ │ │ │ ├── sidebar.component.scss │ │ │ │ └── sidebar.component.ts │ │ ├── services │ │ │ ├── editor-state.service.ts │ │ │ ├── file-loader │ │ │ │ ├── loader-factory.service.ts │ │ │ │ ├── loaders │ │ │ │ │ ├── _mock │ │ │ │ │ │ └── mockFile.ts │ │ │ │ │ ├── gist-file-loader.service.ts │ │ │ │ │ ├── github-file-loader.service.ts │ │ │ │ │ ├── local-filer-loader.ts │ │ │ │ │ ├── mock-filer-loader.ts │ │ │ │ │ └── zip-file-loader.service.ts │ │ │ │ └── type.ts │ │ │ ├── file-saver │ │ │ │ ├── file-saver.service.ts │ │ │ │ ├── storage │ │ │ │ │ ├── gist.ts │ │ │ │ │ └── local.ts │ │ │ │ └── type.ts │ │ │ ├── node-container.service.ts │ │ │ └── shortcut.ts │ │ └── utils │ │ │ └── file.ts │ └── home │ │ ├── components │ │ └── header │ │ │ ├── header.component.html │ │ │ ├── header.component.scss │ │ │ └── header.component.ts │ │ ├── home.component.html │ │ ├── home.component.scss │ │ ├── home.component.ts │ │ └── home.service.ts ├── assets │ └── imgs │ │ ├── github.png │ │ ├── header-logo.png │ │ ├── home-github.svg │ │ ├── home-local.svg │ │ ├── home-template-bg.png │ │ ├── prettier.svg │ │ └── template │ │ ├── angular.svg │ │ ├── next.svg │ │ ├── node.svg │ │ ├── react.svg │ │ ├── solid.svg │ │ ├── static.svg │ │ ├── vanilla.svg │ │ └── vue.svg ├── environments │ ├── environment.development.ts │ └── environment.ts ├── global.d.ts ├── index.html ├── main.ts └── styles.scss ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json ├── vercel.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": ["*.ts"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:@typescript-eslint/stylistic", 9 | "plugin:@angular-eslint/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "plugins": ["eslint-plugin-unused-imports"], 13 | "rules": { 14 | "@angular-eslint/directive-selector": [ 15 | "error", 16 | { 17 | "type": "attribute", 18 | "prefix": "app", 19 | "style": "camelCase" 20 | } 21 | ], 22 | "@angular-eslint/component-selector": [ 23 | "error", 24 | { 25 | "type": "element", 26 | "prefix": "app", 27 | "style": "kebab-case" 28 | } 29 | ], 30 | "@typescript-eslint/triple-slash-reference": "off", 31 | "@typescript-eslint/no-unused-vars": "off", 32 | "no-unused-vars": "off", 33 | "unused-imports/no-unused-imports": "off", 34 | "no-useless-constructor": "off", 35 | "no-useless-escape": "off", 36 | "@typescript-eslint/no-explicit-any": "off", 37 | "@typescript-eslint/no-empty-function": "off" 38 | } 39 | }, 40 | { 41 | "files": ["*.html"], 42 | "extends": ["plugin:@angular-eslint/template/recommended"], 43 | "rules": {} 44 | }, 45 | { 46 | "files": ["*.html"], 47 | "excludedFiles": ["*inline-template-*.component.html"], 48 | "extends": ["plugin:prettier/recommended"], 49 | "rules": { 50 | "prettier/prettier": ["error", { "parser": "angular" }] 51 | } 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | npx lint-staged -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.ts": [ 3 | "npx eslint --fix" 4 | ], 5 | "*.html": [ 6 | "npx eslint --fix" 7 | ], 8 | "*.scss": [ 9 | "npx eslint --fix" 10 | ] 11 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "arrowParens": "avoid", 8 | "trailingComma": "es5", 9 | "printWidth": 120 10 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template", "dbaeumer.vscode-eslint", ] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "Launch Chrome against localhost", 7 | "type": "chrome", 8 | "request": "launch", 9 | "url": "http://localhost:4200/edit?source=mock", 10 | // "url": "http://localhost:4200?source=http://localhost:4200/templates/standard.zip&terminal=dev&pkgManager=yarn", 11 | // "url": "http://localhost:4200?source=http://localhost:4200/templates/react-google-map.zip&terminal=start", 12 | // "url": " http://localhost:4200/edit?terminal=dev&source=https://github.com/chenxiaoyao6228/fe-notes/tree/main/Editor/_demo/webcontainers-express-app", 13 | "sourceMaps": true, 14 | "webRoot": "${workspaceRoot}", 15 | "skipFiles": [ 16 | "node_modules/**", 17 | "**/node_modules/**" 18 | ] 19 | }, 20 | { 21 | "name": "ng test", 22 | "type": "chrome", 23 | "request": "launch", 24 | "preLaunchTask": "npm: test", 25 | "url": "http://localhost:9876/debug.html" 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[html]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "always" 6 | }, 7 | "editor.formatOnSave": true 8 | }, 9 | "[typescript]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode", 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": "always" 13 | }, 14 | "editor.formatOnSave": true 15 | }, 16 | "[scss]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode", 18 | "editor.codeActionsOnSave": { 19 | "source.fixAll.eslint": "always" 20 | }, 21 | "editor.formatOnSave": true 22 | }, 23 | "editor.suggest.snippetsPreventQuickSuggestions": false, 24 | "editor.inlineSuggest.enabled": true 25 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 2 | 3 | - Create projects from existing templates (10+ templates included) without cloning or installing anything locally 4 | - Support import from [GitHub folder](https://code-studio.chenxiaoyao.cn/edit?source=https://github.com/chenxiaoyao6228/fe-notes/tree/main/React%E7%9B%B8%E5%85%B3/_demo/react-starter) or local folder 5 | - Sync your project to Gist (if GitHub token is provided) or download as ZIP 6 | - File Manager: easily create, edit, remove files and folders, with drag and drop support 7 | - Basic code editing abilities: path IntelliSense, go to definition, etc. 8 | - Integrated Console: no need to press 'F12' to open DevTools 9 | - Prettier as the default code formatter 10 | - Keyboard shortcuts support: 'CTRL(CMD) + /' to show the shortcut list 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Code Studio Logo 4 | 5 |

6 | 7 |

Code Studio

8 |

Web Code Runner for JS and NodeJS

9 | 10 | ## Features 11 | 12 | - [x] Create projects from existing templates (10+ templates included) without cloning or installing anything locally 13 | - [x] Support import from [GitHub folder](https://code-studio.chenxiaoyao.cn/edit?source=https://github.com/chenxiaoyao6228/fe-notes/tree/main/React%E7%9B%B8%E5%85%B3/_demo/react-starter) or local folder 14 | - [x] Sync your project to Gist (if GitHub token is provided) or download as ZIP 15 | - [x] File Manager: easily create, edit, remove files and folders, with drag and drop support 16 | - [x] Basic code editing abilities: path IntelliSense, go to definition, etc. 17 | - [x] Integrated Console: no need to press 'F12' to open DevTools 18 | - [x] Prettier as the default code formatter 19 | - [x] Keyboard shortcuts support: 'CTRL(CMD) + /' to show the shortcut list 20 | - [ ] Various VSCode theme support 21 | - [ ] File Sharing 22 | - [ ] Diff editor 23 | 24 | You can experience code-studio through [this link](https://code-studio.chenxiaoyao.cn) 25 | 26 | ## Screenshots 27 | 28 | ![Code Studio](./screenshots/screenshot.png) 29 | 30 | ![Code Studio](./screenshots/screenshot-2.png) 31 | 32 | ## Development 33 | 34 | Code Studio uses the following tech stack: 35 | 36 | > Angular 18 + RxJS + WebContainers + Monaco Editor + XtermJS 37 | 38 | Run the following commands to get started: 39 | 40 | ```sh 41 | yarn install 42 | yarn start 43 | ``` 44 | 45 | ## Contribute 46 | 47 | For anyone interested in the project, any kind of contribution is greatly appreciated. 48 | 49 | ## Reference 50 | 51 | - https://webcontainers.io 52 | - https://microsoft.github.io/monaco-editor/ 53 | - https://angular.dev/ 54 | - https://material.angular.io/ 55 | - https://github.com/xtermjs/xterm.js 56 | 57 | Great thanks to these awesome open-source projects! 58 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "CodeStudio": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:application", 19 | "options": { 20 | "outputPath": "dist/code-studio", 21 | "index": "src/index.html", 22 | "browser": "src/main.ts", 23 | "tsConfig": "tsconfig.app.json", 24 | "inlineStyleLanguage": "scss", 25 | "assets": [ 26 | "src/assets", 27 | { 28 | "glob": "**/*", 29 | "input": "public" 30 | }, 31 | { 32 | "glob": "**/*", 33 | "input": "./node_modules/monaco-editor/min/vs", 34 | "output": "/assets/vs/" 35 | } 36 | ], 37 | "styles": [ 38 | "@angular/material/prebuilt-themes/magenta-violet.css", 39 | "src/styles.scss" 40 | ] 41 | }, 42 | "configurations": { 43 | "production": { 44 | "budgets": [ 45 | { 46 | "type": "initial", 47 | "maximumWarning": "2mb", 48 | "maximumError": "5mb" 49 | // "maximumWarning": "500kB", 50 | // "maximumError": "1MB" 51 | }, 52 | { 53 | "type": "anyComponentStyle", 54 | "maximumWarning": "2kB", 55 | "maximumError": "5kB" 56 | } 57 | ], 58 | "outputHashing": "all" 59 | }, 60 | "development": { 61 | "optimization": false, 62 | "extractLicenses": false, 63 | "sourceMap": true, 64 | "fileReplacements": [ 65 | { 66 | "replace": "src/environments/environment.ts", 67 | "with": "src/environments/environment.development.ts" 68 | } 69 | ] 70 | } 71 | }, 72 | "defaultConfiguration": "production" 73 | }, 74 | "serve": { 75 | "options": { 76 | "headers": { 77 | "Cross-Origin-Opener-Policy": "same-origin", 78 | "Cross-Origin-Embedder-Policy": "require-corp" 79 | } 80 | }, 81 | "builder": "@angular-devkit/build-angular:dev-server", 82 | "configurations": { 83 | "production": { 84 | "buildTarget": "CodeStudio:build:production" 85 | }, 86 | "development": { 87 | "buildTarget": "CodeStudio:build:development" 88 | } 89 | }, 90 | "defaultConfiguration": "development" 91 | }, 92 | "extract-i18n": { 93 | "builder": "@angular-devkit/build-angular:extract-i18n" 94 | }, 95 | "test": { 96 | "builder": "@angular-devkit/build-angular:karma", 97 | "options": { 98 | "tsConfig": "tsconfig.spec.json", 99 | "inlineStyleLanguage": "scss", 100 | "assets": [ 101 | "src/assets", 102 | { 103 | "glob": "**/*", 104 | "input": "public" 105 | }, 106 | { 107 | "glob": "**/*", 108 | "input": "./node_modules/monaco-editor/min/vs", 109 | "output": "/assets/vs/" 110 | } 111 | ], 112 | "styles": [ 113 | "@angular/material/prebuilt-themes/magenta-violet.css", 114 | "src/styles.scss" 115 | ] 116 | } 117 | }, 118 | "lint": { 119 | "builder": "@angular-eslint/builder:lint", 120 | "options": { 121 | "lintFilePatterns": [ 122 | "src/**/*.ts", 123 | "src/**/*.html" 124 | ] 125 | } 126 | } 127 | } 128 | } 129 | }, 130 | "cli": { 131 | "analytics": "b001fd87-29ae-452b-8695-66d88a860d5c", 132 | "schematicCollections": [ 133 | "@angular-eslint/schematics" 134 | ] 135 | } 136 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import unusedImports from 'eslint-plugin-unused-imports'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import js from '@eslint/js'; 5 | import { FlatCompat } from '@eslint/eslintrc'; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all, 13 | }); 14 | 15 | export default [ 16 | ...compat 17 | .extends( 18 | 'eslint:recommended', 19 | 'plugin:@typescript-eslint/recommended', 20 | 'plugin:@typescript-eslint/stylistic', 21 | 'plugin:@angular-eslint/recommended', 22 | 'plugin:prettier/recommended' 23 | ) 24 | .map((config) => ({ 25 | ...config, 26 | files: ['**/*.ts'], 27 | })), 28 | { 29 | files: ['**/*.ts'], 30 | 31 | plugins: { 32 | 'unused-imports': unusedImports, 33 | }, 34 | 35 | rules: { 36 | '@angular-eslint/directive-selector': [ 37 | 'error', 38 | { 39 | type: 'attribute', 40 | prefix: 'app', 41 | style: 'camelCase', 42 | }, 43 | ], 44 | 45 | '@angular-eslint/component-selector': [ 46 | 'error', 47 | { 48 | type: 'element', 49 | prefix: 'app', 50 | style: 'kebab-case', 51 | }, 52 | ], 53 | 54 | 'prettier/prettier': [ 55 | 'error', 56 | { 57 | printWidth: 120, // Make sure this value matches the one in your .prettierrc file 58 | }, 59 | ], 60 | 61 | '@typescript-eslint/triple-slash-reference': 'off', 62 | '@typescript-eslint/no-unused-vars': 'off', 63 | 'no-unused-vars': 'off', 64 | 'unused-imports/no-unused-imports': 'off', 65 | 'no-useless-constructor': 'off', 66 | 'no-useless-escape': 'off', 67 | '@typescript-eslint/no-explicit-any': 'off', 68 | '@typescript-eslint/no-empty-function': 'off', 69 | }, 70 | }, 71 | ...compat.extends('plugin:@angular-eslint/template/recommended').map((config) => ({ 72 | ...config, 73 | files: ['**/*.html'], 74 | })), 75 | { 76 | files: ['**/*.html'], 77 | rules: {}, 78 | }, 79 | ...compat.extends('plugin:prettier/recommended').map((config) => ({ 80 | ...config, 81 | files: ['**/*.html'], 82 | ignores: ['**/*inline-template-*.component.html'], 83 | })), 84 | { 85 | files: ['**/*.html'], 86 | ignores: ['**/*inline-template-*.component.html'], 87 | 88 | rules: { 89 | 'prettier/prettier': [ 90 | 'error', 91 | { 92 | parser: 'angular', 93 | }, 94 | ], 95 | }, 96 | }, 97 | ]; 98 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src/app/editor/features/main/ouput/console" 4 | ], 5 | "ext": "ts", 6 | "exec": "node scripts/genProxyScript.js" 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-studio", 3 | "version": "1.0.0", 4 | "author": "chenxiaoyao6228@163.com", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/chenxiaoyao6228/codestudio" 8 | }, 9 | "scripts": { 10 | "ng": "ng", 11 | "start": "ng serve", 12 | "build": "ng build", 13 | "watch": "ng build --watch --configuration development", 14 | "build:proxy-console": "node scripts/genProxyScript.js", 15 | "watch:proxy-console": "nodemon", 16 | "test": "ng test", 17 | "lint": "npx eslint --fix", 18 | "prepare": "husky" 19 | }, 20 | "private": true, 21 | "dependencies": { 22 | "@angular/animations": "^18.0.0", 23 | "@angular/cdk": "18.0.2", 24 | "@angular/common": "^18.0.0", 25 | "@angular/compiler": "^18.0.0", 26 | "@angular/core": "^18.0.0", 27 | "@angular/forms": "^18.0.0", 28 | "@angular/material": "18.0.2", 29 | "@angular/platform-browser": "^18.0.0", 30 | "@angular/platform-browser-dynamic": "^18.0.0", 31 | "@angular/router": "^18.0.0", 32 | "@octokit/rest": "^21.0.0", 33 | "@webcontainer/api": "^1.1.9", 34 | "@xterm/addon-fit": "^0.10.0", 35 | "@xterm/xterm": "^5.5.0", 36 | "hotkeys-js": "^3.13.7", 37 | "jszip": "^3.10.1", 38 | "lodash-es": "^4.17.21", 39 | "monaco-editor": "^0.50.0", 40 | "rxjs": "~7.8.0" 41 | }, 42 | "devDependencies": { 43 | "@angular-devkit/build-angular": "^18.0.2", 44 | "@angular-eslint/eslint-plugin-template": "^18.1.0", 45 | "@angular-eslint/template-parser": "^18.1.0", 46 | "@angular/cli": "^18.0.2", 47 | "@angular/compiler-cli": "^18.0.0", 48 | "@babel/cli": "^7.24.8", 49 | "@babel/core": "^7.24.8", 50 | "@babel/preset-env": "^7.24.8", 51 | "@babel/preset-typescript": "^7.24.7", 52 | "@eslint/eslintrc": "^3.1.0", 53 | "@eslint/js": "^9.7.0", 54 | "@types/jasmine": "~5.1.0", 55 | "@types/lodash": "^4.17.6", 56 | "@typescript-eslint/eslint-plugin": "^7.16.1", 57 | "@typescript-eslint/parser": "^7.16.1", 58 | "adm-zip": "^0.5.14", 59 | "angular-eslint": "18.0.1", 60 | "eslint": "^9.3.0", 61 | "eslint-config-prettier": "^9.1.0", 62 | "eslint-plugin-prettier": "^5.2.1", 63 | "eslint-plugin-unused-imports": "^4.0.0", 64 | "husky": "^9.0.11", 65 | "jasmine-core": "~5.1.0", 66 | "karma": "~6.4.0", 67 | "karma-chrome-launcher": "~3.2.0", 68 | "karma-coverage": "~2.2.0", 69 | "karma-jasmine": "~5.1.0", 70 | "karma-jasmine-html-reporter": "~2.1.0", 71 | "lint-staged": "^15.2.7", 72 | "nodemon": "^3.1.4", 73 | "prettier": "^3.3.3", 74 | "prettier-eslint": "^16.3.0", 75 | "terser": "^5.31.2", 76 | "tslib": "^2.3.0", 77 | "typescript": "~5.4.2" 78 | } 79 | } -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 404 7 | 8 | 9 |

404

10 | 11 | 12 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxiaoyao6228/code-studio/1c666275313afaa74532fad5d6631125bb8f3c90/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxiaoyao6228/code-studio/1c666275313afaa74532fad5d6631125bb8f3c90/public/logo.jpg -------------------------------------------------------------------------------- /public/proxy-console/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Proxy console 7 | 8 | 9 |

Proxy console

10 | 11 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /public/proxy-console/index.js: -------------------------------------------------------------------------------- 1 | // This file is auto-generated, please do not modify by hand 2 | "use strict";function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _slicedToArray(e,r){return _arrayWithHoles(e)||_iterableToArrayLimit(e,r)||_unsupportedIterableToArray(e,r)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(e,r){if(e){if("string"==typeof e)return _arrayLikeToArray(e,r);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(e,r):void 0}}function _arrayLikeToArray(e,r){(null==r||r>e.length)&&(r=e.length);for(var n=0,t=Array(r);n { 24 | if (err) { 25 | console.error("Error during Babel transformation:", err); 26 | return; 27 | } 28 | 29 | try { 30 | const minified = await minify(result.code, { 31 | output: { 32 | comments: false, 33 | }, 34 | }); 35 | 36 | const escapedCode = minified.code 37 | .replace(/\\/g, "\\\\") // Escape backslashes 38 | .replace(/`/g, "\\`") // Escape backticks 39 | .replace(/\$/g, "\\$") // Escape dollar signs 40 | .replace(/\r?\n|\r/g, "\\n"); // Escape newlines 41 | const outputString = `export const proxyConsoleScript = \`${escapedCode}\`;`; 42 | 43 | fs.writeFileSync( 44 | outputFilePath, 45 | `// This file is auto-generated, please do not modify by hand 46 | ${minified.code}` 47 | ); 48 | fs.writeFileSync( 49 | outputFileStringPath, 50 | `// This file is auto-generated, please do not modify by hand 51 | ${outputString}` 52 | ); 53 | console.log( 54 | "TypeScript file has been transpiled and output to public/proxy-console/index.js" 55 | ); 56 | } catch (minifyErr) { 57 | console.error("Error during minification:", minifyErr); 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /scripts/zip.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const AdmZip = require('adm-zip'); 4 | 5 | function deleteNodeModules(folderPath) { 6 | const files = fs.readdirSync(folderPath); 7 | 8 | files.forEach(file => { 9 | const filePath = path.join(folderPath, file); 10 | const stats = fs.statSync(filePath); 11 | 12 | if (stats.isDirectory()) { 13 | if (file === 'node_modules') { 14 | fs.rmSync(filePath, { recursive: true, force: true }); 15 | console.log(`Deleted ${filePath}`); 16 | } else { 17 | deleteNodeModules(filePath); 18 | } 19 | } 20 | }); 21 | } 22 | 23 | function zipFolderRecursive(folderPath, zip, baseFolder) { 24 | const files = fs.readdirSync(folderPath); 25 | 26 | files.forEach(file => { 27 | const filePath = path.join(folderPath, file); 28 | const relativePath = path.relative(baseFolder, filePath); 29 | 30 | const stats = fs.statSync(filePath); 31 | if (stats.isDirectory()) { 32 | zip.addFile(relativePath + '/', Buffer.alloc(0), '', 0o755); 33 | zipFolderRecursive(filePath, zip, baseFolder); 34 | } else { 35 | const fileData = fs.readFileSync(filePath); 36 | zip.addFile(relativePath, fileData, '', 0o644); 37 | } 38 | }); 39 | } 40 | 41 | function zipFolder(sourceFolder, targetZip) { 42 | const zip = new AdmZip(); 43 | deleteNodeModules(sourceFolder); 44 | zipFolderRecursive(sourceFolder, zip, sourceFolder); 45 | zip.writeZip(targetZip); 46 | console.log(`Zipped ${sourceFolder} to ${targetZip}`); 47 | } 48 | 49 | function unzipFolder(zipFilePath, targetFolder) { 50 | const zip = new AdmZip(zipFilePath); 51 | zip.extractAllTo(targetFolder, true); 52 | console.log(`Unzipped ${zipFilePath} to ${targetFolder}`); 53 | } 54 | 55 | const args = process.argv.slice(2); 56 | 57 | let name = ''; 58 | let action = ''; 59 | 60 | args.forEach((arg, index) => { 61 | if (arg === '--name' && args[index + 1]) { 62 | name = args[index + 1]; 63 | } else if (arg === '--zip') { 64 | action = 'zip'; 65 | } else if (arg === '--unzip') { 66 | action = 'unzip'; 67 | } 68 | }); 69 | 70 | if (!name) { 71 | console.log('Please provide a folder name using --name argument.'); 72 | } else if (action === 'zip') { 73 | const sourceFolder = `/home/york/projects/CodeStudio/public/templates/${name}`; 74 | const targetZip = `${sourceFolder}.zip`; 75 | zipFolder(sourceFolder, targetZip); 76 | } else if (action === 'unzip') { 77 | const sourceZip = `/home/york/projects/CodeStudio/public/templates/${name}.zip`; 78 | const targetFolder = `/home/york/projects/CodeStudio/public/templates/${name}`; 79 | unzipFolder(sourceZip, targetFolder); 80 | } else { 81 | console.log('Please provide --zip or --unzip argument.'); 82 | } 83 | -------------------------------------------------------------------------------- /src/app/_shared/components/confirm-dialog/confirm-dialog.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | import { MatButton, MatButtonModule } from '@angular/material/button'; 3 | import { 4 | MatDialogRef, 5 | MAT_DIALOG_DATA, 6 | MatDialog, 7 | MatDialogTitle, 8 | MatDialogContent, 9 | MatDialogActions, 10 | MatDialogClose, 11 | } from '@angular/material/dialog'; 12 | 13 | @Component({ 14 | standalone: true, 15 | selector: 'app-confirm-dialog', 16 | template: ` 17 |

Confirm

18 |
19 |

{{ data.message }}

20 |
21 |
22 | 23 | 24 |
25 | `, 26 | imports: [MatButtonModule, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose], 27 | }) 28 | export class ConfirmDialogComponent { 29 | constructor( 30 | public dialogRef: MatDialogRef, 31 | @Inject(MAT_DIALOG_DATA) public data: any 32 | ) {} 33 | 34 | onNoClick(): void { 35 | this.dialogRef.close(false); 36 | } 37 | 38 | onYesClick(): void { 39 | this.dialogRef.close(true); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/_shared/components/github-token-dialog/github-token-dialog.component.html: -------------------------------------------------------------------------------- 1 |

Enter Your GitHub Token

2 |
3 | 4 | GitHub Token 5 | 6 | 7 |

8 | Code Studio use Gist as storage service. If you don't have a Github token yet, you can create a new one 9 | here 10 | . Make sure to select the gist scope in the scopes section. 11 |

12 |
13 |
14 | 15 | 16 |
17 | -------------------------------------------------------------------------------- /src/app/_shared/components/github-token-dialog/github-token-dialog.component.scss: -------------------------------------------------------------------------------- 1 | mat-form-field { 2 | width: 100%; 3 | } 4 | 5 | .mat-mdc-dialog-content { 6 | padding-bottom: 16px; 7 | .custom-link { 8 | text-decoration: underline; 9 | color: #fff; 10 | font-weight: strong; 11 | } 12 | } 13 | 14 | .mat-dialog-actions { 15 | display: flex; 16 | justify-content: flex-end; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/_shared/components/github-token-dialog/github-token-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { 5 | MatDialogActions, 6 | MatDialogClose, 7 | MatDialogContent, 8 | MatDialogRef, 9 | MatDialogTitle, 10 | } from '@angular/material/dialog'; 11 | import { MatFormFieldModule } from '@angular/material/form-field'; 12 | import { MatInputModule } from '@angular/material/input'; 13 | 14 | @Component({ 15 | standalone: true, 16 | selector: 'app-github-token-dialog', 17 | templateUrl: './github-token-dialog.component.html', 18 | imports: [ 19 | MatFormFieldModule, 20 | MatInputModule, 21 | FormsModule, 22 | MatButtonModule, 23 | MatDialogTitle, 24 | MatDialogContent, 25 | MatDialogActions, 26 | MatDialogClose, 27 | ], 28 | styleUrls: ['./github-token-dialog.component.scss'], 29 | }) 30 | export class GitHubTokenDialogComponent { 31 | token = ''; 32 | 33 | constructor(public dialogRef: MatDialogRef) {} 34 | 35 | onNoClick(): void { 36 | this.dialogRef.close(); 37 | } 38 | 39 | onSave(): void { 40 | this.dialogRef.close(this.token); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/_shared/components/github-url-dialog/github-url-dialog.component.html: -------------------------------------------------------------------------------- 1 |

Enter GitHub URL

2 |
3 |
4 | 5 | GitHub URL 6 | 14 | @if (githubUrlInput.invalid && githubUrlInput.touched) { 15 | A Valid GitHub URL is required 16 | } 17 | 18 |

19 | Please enter the URL of your GitHub repository or 20 | a sub folder of a repo to import your project. Make sure the repository is public and have a 21 | package.json file in the root 22 |

23 |
24 |
25 | 26 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /src/app/_shared/components/github-url-dialog/github-url-dialog.component.scss: -------------------------------------------------------------------------------- 1 | mat-form-field { 2 | width: 100%; 3 | } 4 | 5 | .mat-mdc-dialog-content { 6 | padding-bottom: 16px; 7 | .custom-link { 8 | text-decoration: underline; 9 | color: #fff; 10 | font-weight: strong; 11 | } 12 | } 13 | 14 | .mat-dialog-actions { 15 | display: flex; 16 | justify-content: flex-end; 17 | } 18 | -------------------------------------------------------------------------------- /src/app/_shared/components/github-url-dialog/github-url-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, Inject } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { 5 | MatDialogRef, 6 | MAT_DIALOG_DATA, 7 | MatDialogTitle, 8 | MatDialogContent, 9 | MatDialogActions, 10 | MatDialogClose, 11 | } from '@angular/material/dialog'; 12 | import { MatFormFieldModule } from '@angular/material/form-field'; 13 | import { MatInputModule } from '@angular/material/input'; 14 | 15 | @Component({ 16 | selector: 'app-github-url-dialog', 17 | templateUrl: './github-url-dialog.component.html', 18 | styleUrls: ['./github-url-dialog.component.scss'], 19 | standalone: true, 20 | imports: [ 21 | MatFormFieldModule, 22 | MatInputModule, 23 | FormsModule, 24 | MatButtonModule, 25 | MatDialogTitle, 26 | MatDialogContent, 27 | MatDialogActions, 28 | MatDialogClose, 29 | ], 30 | }) 31 | export class GithubUrlDialogComponent { 32 | githubUrl = ''; 33 | readonly dialogRef = inject(MatDialogRef); 34 | 35 | constructor(@Inject(MAT_DIALOG_DATA) public data: unknown) {} 36 | 37 | onNoClick(): void { 38 | this.dialogRef.close(); 39 | } 40 | 41 | onSave(): void { 42 | if (this.githubUrl.trim()) { 43 | this.dialogRef.close(this.githubUrl); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/_shared/components/template-modal/config.ts: -------------------------------------------------------------------------------- 1 | import { environment } from '@src/environments/environment'; 2 | 3 | export interface ITemplateItem { 4 | name: string; 5 | url: string; 6 | terminal: string; 7 | icon: string; 8 | } 9 | 10 | const BASE_URL = environment.baseUrl; 11 | 12 | export const TEMPLATES_CONFIG = [ 13 | { 14 | name: 'React', 15 | desc: 'Typescript', 16 | url: `${BASE_URL}/templates/react-ts.zip`, 17 | icon: '/assets/imgs/template/react.svg', 18 | terminal: 'dev', 19 | }, 20 | { 21 | name: 'Vue3', 22 | desc: 'Typescript', 23 | url: `${BASE_URL}/templates/vue3-ts.zip`, 24 | icon: '/assets/imgs/template/vue.svg', 25 | terminal: 'dev', 26 | }, 27 | { 28 | name: 'Solid', 29 | desc: 'Typescript', 30 | url: `${BASE_URL}/templates/solid-ts.zip`, 31 | icon: '/assets/imgs/template/solid.svg', 32 | terminal: 'dev', 33 | }, 34 | { 35 | name: 'Angular', 36 | desc: 'Typescript', 37 | url: `${BASE_URL}/templates/angular.zip`, 38 | icon: '/assets/imgs/template/angular.svg', 39 | terminal: 'start', 40 | }, 41 | { 42 | name: 'Vanilla', 43 | desc: 'Typescript', 44 | url: `${BASE_URL}/templates/vanilla.zip`, 45 | icon: '/assets/imgs/template/vanilla.svg', 46 | terminal: 'dev', 47 | }, 48 | { 49 | name: 'Static', 50 | desc: 'HTML/CSS/JS', 51 | url: `${BASE_URL}/templates/static.zip`, 52 | icon: '/assets/imgs/template/static.svg', 53 | terminal: 'start', 54 | }, 55 | { 56 | name: 'Node', 57 | desc: 'Blank project', 58 | url: `${BASE_URL}/templates/node.zip`, 59 | icon: '/assets/imgs/template/node.svg', 60 | terminal: 'dev', 61 | }, 62 | { 63 | name: 'Next', 64 | desc: 'Node', 65 | url: `${BASE_URL}/templates/next.zip`, 66 | icon: '/assets/imgs/template/next.svg', 67 | terminal: 'dev', 68 | }, 69 | ]; 70 | -------------------------------------------------------------------------------- /src/app/_shared/components/template-modal/template-modal.component.html: -------------------------------------------------------------------------------- 1 |

2 | Choose a template 3 | 4 |
5 | close 6 |
7 |
8 |

9 | 10 |
11 | @for (item of templateList(); track item.icon) { 12 |
13 |
14 |
15 |
{{ item.name }}
16 | {{ item.desc }} 17 |
18 |
19 | } 20 |
21 |
22 | -------------------------------------------------------------------------------- /src/app/_shared/components/template-modal/template-modal.component.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | display: flex; 3 | align-items: center; 4 | } 5 | .mat-mdc-dialog-content { 6 | .template-list { 7 | overflow: auto; 8 | 9 | .template-item { 10 | width: 160px; 11 | height: 70px; 12 | display: flex; 13 | align-items: center; 14 | float: left; 15 | margin-right: 10px; 16 | margin-bottom: 20px; 17 | cursor: pointer; 18 | transition: all 0.3s; 19 | border-radius: 4px; 20 | &:hover { 21 | background-color: rgba(36, 36, 36, 0.1); 22 | border: 1px solid #fff; 23 | } 24 | 25 | .icon { 26 | width: 60px; 27 | height: 60px; 28 | background-size: contain; 29 | background-repeat: no-repeat; 30 | background-position: center; 31 | margin-left: 10px; 32 | } 33 | .wrap { 34 | margin-left: 20px; 35 | .name { 36 | overflow: hidden; 37 | font-weight: bold; 38 | font-size: 18px; 39 | } 40 | .desc { 41 | overflow: hidden; 42 | font-weight: 400; 43 | font-size: 85%; 44 | text-overflow: ellipsis; 45 | white-space: nowrap; 46 | opacity: 0.65; 47 | } 48 | } 49 | } 50 | } 51 | } 52 | .mat-mdc-dialog-actions { 53 | position: absolute; 54 | right: 0; 55 | .close { 56 | &:hover { 57 | cursor: pointer; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/_shared/components/template-modal/template-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; 2 | import { ITemplateItem, TEMPLATES_CONFIG } from '@app/_shared/components/template-modal/config'; 3 | import { 4 | MatDialog, 5 | MatDialogActions, 6 | MatDialogClose, 7 | MatDialogContent, 8 | MatDialogModule, 9 | MatDialogTitle, 10 | } from '@angular/material/dialog'; 11 | import { Router } from '@angular/router'; 12 | import { MatIcon } from '@angular/material/icon'; 13 | 14 | @Component({ 15 | selector: 'app-template-modal', 16 | standalone: true, 17 | imports: [MatDialogModule, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose, MatIcon], 18 | templateUrl: './template-modal.component.html', 19 | styleUrls: ['./template-modal.component.scss'], 20 | changeDetection: ChangeDetectionStrategy.OnPush, 21 | }) 22 | export class TemplateModalComponent { 23 | dialog = inject(MatDialog); 24 | router = inject(Router); // Inject the Router 25 | 26 | templateList = signal(TEMPLATES_CONFIG); 27 | 28 | selectTemplate(item: ITemplateItem) { 29 | const queryString = `source=${encodeURIComponent(item.url)}&terminal=${encodeURIComponent(item.terminal)}`; 30 | window.location.href = `${window.location.origin}/edit/?${queryString}`; 31 | this.closeModal(); // Close the modal after selection 32 | } 33 | 34 | closeModal() { 35 | this.dialog.closeAll(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/_shared/constant.ts: -------------------------------------------------------------------------------- 1 | export const PROJECT_KEY = 'codestudio'; 2 | -------------------------------------------------------------------------------- /src/app/_shared/service/Emitter.ts: -------------------------------------------------------------------------------- 1 | export class EventEmitter { 2 | private events: { [K in keyof T]?: ((payload: T[K]) => void)[] } = {}; 3 | 4 | on(event: K, listener: (payload: T[K]) => void): void { 5 | if (!this.events[event]) { 6 | this.events[event] = []; 7 | } 8 | this.events[event]!.push(listener); 9 | } 10 | 11 | off(event: K, listener: (payload: T[K]) => void): void { 12 | if (!this.events[event]) return; 13 | this.events[event] = this.events[event]!.filter(l => l !== listener); 14 | } 15 | 16 | emit(event: K, payload: T[K]): void { 17 | if (!this.events[event]) return; 18 | this.events[event]!.forEach(listener => listener(payload)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/_shared/service/device-guard.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { CanActivate, Router } from '@angular/router'; 3 | import { DeviceService } from './device.service'; 4 | import { MatSnackBar } from '@angular/material/snack-bar'; 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class PcOnlyGuard implements CanActivate { 10 | private deviceService = inject(DeviceService); 11 | private snackBar = inject(MatSnackBar); 12 | constructor() {} 13 | 14 | canActivate(): boolean { 15 | if (this.deviceService.isPC()) { 16 | return true; 17 | } else { 18 | this.snackBar.open('This application can only be accessed on a PC.', 'Close', { 19 | duration: 5000, 20 | }); 21 | return false; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/_shared/service/device.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root', 5 | }) 6 | export class DeviceService { 7 | constructor() {} 8 | 9 | isPC(): boolean { 10 | const userAgent = navigator.userAgent.toLowerCase(); 11 | const isMobile = /iphone|ipod|ipad|android|blackberry|windows phone|opera mini|iemobile/i.test(userAgent); 12 | return !isMobile; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/_shared/service/gist.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | import { Octokit } from '@octokit/rest'; 3 | import { LocalStorageService } from './local-storage.service'; 4 | 5 | export interface IGistFile { 6 | filename: string; 7 | type: string; 8 | language: string; 9 | raw_url: string; 10 | size: number; 11 | } 12 | 13 | export interface IGistItem { 14 | id: string; 15 | description: string; 16 | updated_at: string; 17 | url: string; 18 | files: Record; 19 | } 20 | 21 | @Injectable({ 22 | providedIn: 'root', 23 | }) 24 | export class GistService { 25 | private localStorageService = inject(LocalStorageService); 26 | 27 | private createOctokitInstance() { 28 | const token = this.localStorageService.getItem('githubToken'); 29 | return new Octokit({ auth: token }); 30 | } 31 | 32 | private handleError(error: any) { 33 | if (error.status === 401) { 34 | return { 35 | success: false, 36 | status: 401, 37 | message: 'Unauthorized. Please check your GitHub token.', 38 | }; 39 | } 40 | return { success: false, status: error.status, message: error.message }; 41 | } 42 | 43 | async addGist( 44 | params: { title: string; description: string; [key: string]: any }, 45 | files: Record 46 | ) { 47 | try { 48 | const fullDescription = stringifyDescription(params); 49 | const octokit = this.createOctokitInstance(); 50 | const response = await octokit.gists.create({ 51 | description: fullDescription, 52 | public: true, 53 | files, 54 | }); 55 | return { success: true, data: response.data }; 56 | } catch (error) { 57 | return this.handleError(error); 58 | } 59 | } 60 | async deleteGist(params: { gistId: string }) { 61 | const octokit = this.createOctokitInstance(); 62 | try { 63 | await octokit.gists.delete({ gist_id: params.gistId }); 64 | return { success: true }; 65 | } catch (error) { 66 | return this.handleError(error); 67 | } 68 | } 69 | 70 | async deleteMultipleGists(params: { gistIds: string[] }) { 71 | try { 72 | const deletePromises = params.gistIds.map(gistId => this.deleteGist({ gistId })); 73 | await Promise.all(deletePromises); 74 | return { success: true }; 75 | } catch (error) { 76 | return this.handleError(error); 77 | } 78 | } 79 | 80 | async updateGist( 81 | gistId: string, 82 | params: { title: string; description: string; [key: string]: any }, 83 | files: Record 84 | ) { 85 | const octokit = this.createOctokitInstance(); 86 | try { 87 | const response = await octokit.gists.update({ 88 | description: stringifyDescription(params), 89 | gist_id: gistId, 90 | files: files, 91 | }); 92 | return { success: true, data: response.data }; 93 | } catch (error) { 94 | return this.handleError(error); 95 | } 96 | } 97 | 98 | async getGists(params: { page: number; perPage: number }) { 99 | const octokit = this.createOctokitInstance(); 100 | try { 101 | const response = await octokit.gists.list({ 102 | page: params.page, 103 | per_page: params.perPage, 104 | }); 105 | return { success: true, data: response.data }; 106 | } catch (error) { 107 | return this.handleError(error); 108 | } 109 | } 110 | } 111 | 112 | /* 113 | * Gist doesn't support extra keys except description from list query, 114 | * so try to store them in description 115 | * so that we can get some extra information like title in the home page 116 | */ 117 | export function stringifyDescription(params: Record): string { 118 | return Object.keys(params) 119 | .map(key => `${key}: ${params[key]}`) 120 | .join('\n'); 121 | } 122 | 123 | export function parseDescription(description: string): Record { 124 | const lines = description.split('\n'); 125 | const params: Record = {}; 126 | lines.forEach(line => { 127 | const [key, ...rest] = line.split(': '); 128 | if (key) { 129 | params[key] = rest.join(': '); 130 | } 131 | }); 132 | return params; 133 | } 134 | -------------------------------------------------------------------------------- /src/app/_shared/service/local-storage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root', 5 | }) 6 | export class LocalStorageService { 7 | constructor() {} 8 | 9 | setItem(key: string, value: any): void { 10 | localStorage.setItem(key, JSON.stringify(value)); 11 | } 12 | 13 | getItem(key: string): any { 14 | const item = localStorage.getItem(key); 15 | return item ? JSON.parse(item) : null; 16 | } 17 | 18 | removeItem(key: string): void { 19 | localStorage.removeItem(key); 20 | } 21 | 22 | clear(): void { 23 | localStorage.clear(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/_shared/service/redirect-guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { CanActivate, Router, ActivatedRouteSnapshot, UrlTree } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class RedirectGuard implements CanActivate { 9 | constructor(private router: Router) {} 10 | 11 | canActivate( 12 | route: ActivatedRouteSnapshot 13 | ): Observable | Promise | boolean | UrlTree { 14 | const source = route.queryParams['source']; 15 | if (source) { 16 | const queryParams = { ...route.queryParams }; 17 | return this.router.createUrlTree(['/edit'], { queryParams }); 18 | } 19 | return true; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/_shared/types/index.ts: -------------------------------------------------------------------------------- 1 | import { FileSystemTree } from '@webcontainer/api'; 2 | 3 | export interface IStudioAsset { 4 | meta: Record; 5 | files: FileSystemTree; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/_shared/utils/index.ts: -------------------------------------------------------------------------------- 1 | export function formatTime(timeString: string): string { 2 | const date = new Date(timeString); 3 | const options: Intl.DateTimeFormatOptions = { 4 | year: 'numeric', 5 | month: '2-digit', 6 | day: '2-digit', 7 | hour: '2-digit', 8 | minute: '2-digit', 9 | second: '2-digit', 10 | timeZoneName: 'short', 11 | }; 12 | return date.toLocaleDateString('en-US', options); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { IconResolver, MatIconModule, MatIconRegistry } from '@angular/material/icon'; 3 | import { DomSanitizer } from '@angular/platform-browser'; 4 | import { RouterOutlet } from '@angular/router'; 5 | 6 | @Component({ 7 | selector: 'app-root', 8 | standalone: true, 9 | imports: [RouterOutlet, MatIconModule], 10 | template: '', 11 | }) 12 | export class AppComponent { 13 | title = 'CodeStudio'; 14 | constructor(iconRegistry: MatIconRegistry, sanitizer: DomSanitizer) { 15 | const resolver: IconResolver = (name, namespace) => { 16 | return sanitizer.bypassSecurityTrustResourceUrl(`assets/imgs/${name}.svg`); 17 | }; 18 | iconRegistry.addSvgIconResolver(resolver); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | import { Routes } from '@angular/router'; 4 | import { EditorComponent } from './editor/editor.component'; 5 | import { CommonModule } from '@angular/common'; 6 | import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; 7 | import { provideHttpClient, withFetch } from '@angular/common/http'; 8 | import { FormsModule } from '@angular/forms'; 9 | import { HomeComponent } from './home/home.component'; 10 | import { RedirectGuard } from './_shared/service/redirect-guard'; 11 | import { PcOnlyGuard } from './_shared/service/device-guard'; 12 | 13 | export const routes: Routes = [ 14 | { path: '', component: HomeComponent, canActivate: [RedirectGuard, PcOnlyGuard] }, 15 | { 16 | path: 'edit', 17 | component: EditorComponent, 18 | canActivate: [PcOnlyGuard], 19 | }, 20 | ]; 21 | 22 | export const appConfig: ApplicationConfig = { 23 | providers: [ 24 | provideExperimentalZonelessChangeDetection(), 25 | provideRouter(routes), 26 | CommonModule, 27 | FormsModule, 28 | provideAnimationsAsync(), 29 | provideHttpClient(withFetch()), 30 | RedirectGuard, 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/editor/components/shortcut-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { MatDialogRef, MatDialogModule } from '@angular/material/dialog'; 3 | import { CommonModule } from '@angular/common'; 4 | import { MatListModule } from '@angular/material/list'; 5 | import { ShortcutService } from '../services/shortcut'; 6 | import { MatIcon } from '@angular/material/icon'; 7 | 8 | @Component({ 9 | selector: 'app-shortcut-dialog', 10 | standalone: true, 11 | imports: [CommonModule, MatDialogModule, MatListModule, MatIcon], 12 | template: ` 13 |

14 | Keyboard Shortcuts 15 | 16 |
17 | close 18 |
19 |
20 |

21 |
22 | 23 | 24 | {{ shortcut.shortcut }}: {{ shortcut.description }} 25 | 26 | 27 |
28 | `, 29 | styles: [ 30 | ` 31 | .title { 32 | display: flex; 33 | align-items: center; 34 | } 35 | .mat-mdc-dialog-actions { 36 | position: absolute; 37 | right: 0; 38 | .close { 39 | &:hover { 40 | cursor: pointer; 41 | } 42 | } 43 | } 44 | `, 45 | ], 46 | }) 47 | export class ShortcutDialogComponent { 48 | shortcuts = this.shortcutService.getShortcuts(); 49 | 50 | constructor( 51 | private shortcutService: ShortcutService, 52 | private dialogRef: MatDialogRef 53 | ) {} 54 | 55 | close() { 56 | this.dialogRef.close(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/editor/constants/index.ts: -------------------------------------------------------------------------------- 1 | export enum StartupPhase { 2 | NOT_STARTED, 3 | BOOTING, 4 | LOADING_FILES, 5 | INSTALLING, 6 | STARTING_DEV_SERVER, 7 | READY, 8 | ERROR, 9 | } 10 | -------------------------------------------------------------------------------- /src/app/editor/editor.component.ts: -------------------------------------------------------------------------------- 1 | import { ShortcutService } from './services/shortcut'; 2 | import { AfterViewInit, Component, inject, OnDestroy } from '@angular/core'; 3 | import { HeaderComponent } from './features/header/header.component'; 4 | import { FooterComponent } from './features/footer/footer.component'; 5 | import { MainComponent } from './features/main/main.component'; 6 | import { CommonModule } from '@angular/common'; 7 | import { NodeContainerService } from './services/node-container.service'; 8 | import { MatCheckbox } from '@angular/material/checkbox'; 9 | import { ActivatedRoute } from '@angular/router'; 10 | import { FileLoaderFactory } from './services/file-loader/loader-factory.service'; 11 | import { EditorStateService } from './services/editor-state.service'; 12 | import { StartupPhase } from './constants'; 13 | import { CodeEditorService } from './features/main/edit/code-editor/code-editor.service'; 14 | import { TypeLoaderService } from './features/main/edit/code-editor/type-loader.service'; 15 | import { TemplateModalComponent } from '@app/_shared/components/template-modal/template-modal.component'; 16 | import { MatDialog } from '@angular/material/dialog'; 17 | import { ConfirmDialogComponent } from '../_shared/components/confirm-dialog/confirm-dialog'; 18 | import { ShortcutDialogComponent } from './components/shortcut-dialog.component'; 19 | 20 | export interface IRouteParams { 21 | source: string; // mock, local, template name, github folder , zip url 22 | terminal?: string; 23 | pkgManager?: 'npm' | 'yarn' | 'pnpm'; 24 | } 25 | 26 | @Component({ 27 | selector: 'app-editor', 28 | standalone: true, 29 | imports: [ 30 | CommonModule, 31 | HeaderComponent, 32 | FooterComponent, 33 | MainComponent, 34 | MatCheckbox, 35 | TemplateModalComponent, 36 | ShortcutDialogComponent, 37 | ], 38 | providers: [], 39 | template: ` 40 | 41 | 42 | 43 | `, 44 | styles: [ 45 | ` 46 | :host { 47 | height: 100%; 48 | position: relative; 49 | display: flex; 50 | flex-direction: column; 51 | } 52 | `, 53 | ], 54 | }) 55 | export class EditorComponent implements AfterViewInit, OnDestroy { 56 | routeParams: IRouteParams = { 57 | source: 'local', 58 | }; 59 | readonly dialog = inject(MatDialog); 60 | route = inject(ActivatedRoute); 61 | nodeContainerService = inject(NodeContainerService); 62 | fileLoaderService = inject(FileLoaderFactory); 63 | editorStateService = inject(EditorStateService); 64 | typeLoadingService = inject(TypeLoaderService); 65 | codeEditorService = inject(CodeEditorService); 66 | shortcutService = inject(ShortcutService); 67 | 68 | async ngAfterViewInit() { 69 | // support direct access from route params 70 | this.route.queryParams.subscribe((params: any) => { 71 | this.routeParams = params; 72 | this.handleQueryParamsChange(this.routeParams); 73 | }); 74 | } 75 | 76 | async handleQueryParamsChange(params: IRouteParams) { 77 | if (!params.source) { 78 | const dialogRef = this.dialog.open(ConfirmDialogComponent, { 79 | width: '400px', 80 | data: { 81 | message: 82 | 'Source is required in the query params to passed to access this page, go to the page page to choose a template or import a project.', 83 | }, 84 | }); 85 | // throw new Error('Source is required'); 86 | } 87 | // local files have already been loaded by user picker 88 | if (!(this.routeParams.source === 'local')) { 89 | this.editorStateService.setPhase(StartupPhase.LOADING_FILES); 90 | try { 91 | const fileTree = await this.fileLoaderService.loadFiles({ 92 | source: this.routeParams.source || 'mock', 93 | }); 94 | this.editorStateService.setFileTree(fileTree); 95 | } catch (error) { 96 | console.error('Failed to load files: ', error); 97 | throw error; 98 | } 99 | } 100 | try { 101 | await this.nodeContainerService.init(params); 102 | } catch (error) { 103 | console.log('Failed to init webcontainer', error); 104 | } 105 | } 106 | 107 | ngOnDestroy() { 108 | this.shortcutService.cleanup(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/app/editor/features/footer/footer.component.html: -------------------------------------------------------------------------------- 1 |
2 | 4 |
5 |
6 |
7 | 8 |
9 | -------------------------------------------------------------------------------- /src/app/editor/features/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | height: 20px; 3 | line-height: 20px; 4 | font-size: 12px; 5 | display: flex; 6 | justify-content: space-between; 7 | align-content: center; 8 | overflow: hidden; 9 | padding: 0 4px; 10 | } 11 | .left { 12 | } 13 | .middle { 14 | } 15 | .right { 16 | .console { 17 | } 18 | } 19 | 20 | .footer-btn { 21 | // reset all btn attribute 22 | outline: none; 23 | border: none; 24 | padding: 0; 25 | margin: 0; 26 | background: none; 27 | cursor: pointer; 28 | 29 | font-size: 12px; 30 | color: #fff; 31 | margin: 0 4px; 32 | &:hover { 33 | opacity: 0.8; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/editor/features/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; 2 | import { MatButtonModule } from '@angular/material/button'; 3 | import { MainService } from '../main/main.service'; 4 | 5 | @Component({ 6 | selector: 'app-editor-footer', 7 | standalone: true, 8 | imports: [MatButtonModule], 9 | templateUrl: './footer.component.html', 10 | styleUrl: './footer.component.scss', 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class FooterComponent { 14 | mainService = inject(MainService); 15 | 16 | toggleConsole() { 17 | this.mainService.toggleConsole(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/editor/features/header/header.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Code Studio 5 |
6 |
7 | 13 |
14 |
15 | 19 |
20 | 21 | 25 |
26 | 27 | 28 | 29 |
30 |
31 | -------------------------------------------------------------------------------- /src/app/editor/features/header/header.component.scss: -------------------------------------------------------------------------------- 1 | .header-wrap { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-between; 5 | height: 40px; 6 | border-bottom: 1px solid #fff; 7 | background-color: var(--activityBar-background); 8 | box-shadow: 0 1px 3px #0000001a, 0 1px 2px #0000000f; 9 | .left { 10 | margin-left: 10px; 11 | display: flex; 12 | align-items: center; 13 | &:hover { 14 | cursor: pointer; 15 | } 16 | .logo { 17 | width: 20px; 18 | height: 20px; 19 | border-radius: 50%; 20 | margin-right: 6px; 21 | } 22 | .name { 23 | font-weight: 500; 24 | } 25 | } 26 | .middle { 27 | .project-name { 28 | border: none; 29 | background-color: var(--activityBar-background); 30 | height: 30px; 31 | padding: 2px 4px; 32 | color: #fff; 33 | outline: none; 34 | font-weight: 600; 35 | font-size: 16px; 36 | text-align: center; 37 | max-width: 300px; 38 | overflow: hidden; 39 | text-overflow: ellipsis; 40 | white-space: nowrap; 41 | } 42 | } 43 | .right { 44 | display: flex; 45 | align-items: center; 46 | .template { 47 | display: flex; 48 | align-items: center; 49 | margin-right: 10px; 50 | // height: 24px; 51 | &:hover { 52 | cursor: pointer; 53 | } 54 | .icon { 55 | // margin-right: 0px; 56 | } 57 | .text { 58 | // line-height: 24px; 59 | } 60 | } 61 | .save { 62 | margin-right: 10px; 63 | position: relative; 64 | .spinner { 65 | position: absolute; 66 | left: 0; 67 | right: 0; 68 | top: 0; 69 | bottom: 0; 70 | margin: auto; 71 | visibility: hidden; 72 | } 73 | &.loading { 74 | .spinner { 75 | visibility: visible; 76 | } 77 | .save-btn { 78 | opacity: 0.5; 79 | } 80 | } 81 | } 82 | .github { 83 | display: flex; 84 | align-items: center; 85 | &:hover { 86 | cursor: pointer; 87 | } 88 | .logo { 89 | width: 24px; 90 | height: 24px; 91 | border-radius: 50%; 92 | margin-right: 20px; 93 | border: none; 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/app/editor/features/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; 2 | import { MatButton } from '@angular/material/button'; 3 | import { MatDialog } from '@angular/material/dialog'; 4 | import { MatIcon } from '@angular/material/icon'; 5 | import { TemplateModalComponent } from '@app/_shared/components/template-modal/template-modal.component'; 6 | import { FileSaverService } from '../../services/file-saver/file-saver.service'; 7 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 8 | import { MatSnackBar } from '@angular/material/snack-bar'; 9 | import { GitHubTokenDialogComponent } from '@src/app/_shared/components/github-token-dialog/github-token-dialog.component'; 10 | import { LocalStorageService } from '@src/app/_shared/service/local-storage.service'; 11 | import { Router, ActivatedRoute } from '@angular/router'; 12 | import { CodeEditorService } from '../main/edit/code-editor/code-editor.service'; 13 | import { ConfirmDialogComponent } from '@src/app/_shared/components/confirm-dialog/confirm-dialog'; 14 | import { NodeContainerService } from '../../services/node-container.service'; 15 | 16 | const UNTITLED_NAME = 'untitled_project'; 17 | 18 | @Component({ 19 | selector: 'app-editor-header', 20 | standalone: true, 21 | imports: [MatIcon, MatButton, MatProgressSpinnerModule, ConfirmDialogComponent], 22 | templateUrl: './header.component.html', 23 | styleUrl: './header.component.scss', 24 | changeDetection: ChangeDetectionStrategy.OnPush, 25 | }) 26 | export class HeaderComponent implements OnInit { 27 | readonly dialog = inject(MatDialog); 28 | readonly snackBar = inject(MatSnackBar); 29 | logoPath = 'assets/imgs/header-logo.png'; 30 | githubLogoPath = 'assets/imgs/github.png'; 31 | isSaving = signal(false); 32 | projectName = signal(UNTITLED_NAME); 33 | nodeContainerService = inject(NodeContainerService); 34 | codeEditorService = inject(CodeEditorService); 35 | fileSaverService = inject(FileSaverService); 36 | localStorageService = inject(LocalStorageService); 37 | router = inject(Router); 38 | activatedRoute = inject(ActivatedRoute); 39 | editId: string | null = null; 40 | editName: string | null = null; 41 | ngOnInit() { 42 | this.activatedRoute.queryParamMap.subscribe(params => { 43 | this.editId = params.get('editId'); 44 | const editName = params.get('editName'); 45 | if (editName) { 46 | this.projectName.set(editName); 47 | } 48 | }); 49 | 50 | this.nodeContainerService.fileMounted$.subscribe(async fileMounted => { 51 | if (fileMounted && this.projectName() === UNTITLED_NAME) { 52 | const pkgContent = await this.nodeContainerService.readFile('package.json'); 53 | const name = JSON.parse(pkgContent).name || UNTITLED_NAME; 54 | if (name) { 55 | this.projectName.set(name); 56 | } 57 | } 58 | }); 59 | } 60 | 61 | goHome() { 62 | if (this.codeEditorService.hasEdit) { 63 | const dialogRef = this.dialog.open(ConfirmDialogComponent, { 64 | width: '400px', 65 | data: { 66 | message: 'You have unsaved changes. Do you really want to leave?', 67 | }, 68 | }); 69 | 70 | dialogRef.afterClosed().subscribe(async result => { 71 | if (result) { 72 | this._goHome(); 73 | } else { 74 | await this.saveToGist(); 75 | this.codeEditorService.hasEdit = false; 76 | } 77 | }); 78 | } else { 79 | this._goHome(); 80 | } 81 | } 82 | private _goHome() { 83 | window.location.href = '/'; // force reload to release resources 84 | } 85 | 86 | openTemplateModal() { 87 | this.dialog.open(TemplateModalComponent); 88 | } 89 | 90 | async saveToGist() { 91 | let token = this.localStorageService.getItem('githubToken'); 92 | if (!token) { 93 | const dialogRef = this.dialog.open(GitHubTokenDialogComponent, { 94 | width: '400px', 95 | }); 96 | 97 | dialogRef.afterClosed().subscribe(async result => { 98 | if (result) { 99 | token = result; 100 | this.localStorageService.setItem('githubToken', token); 101 | this.performSave(); 102 | } 103 | }); 104 | } else { 105 | this.performSave(); 106 | } 107 | } 108 | 109 | async performSave() { 110 | try { 111 | this.isSaving.set(true); 112 | let name = UNTITLED_NAME; 113 | if (this.projectName() !== UNTITLED_NAME) { 114 | name = this.projectName(); 115 | } 116 | if (!name) { 117 | const pkgContent = await this.nodeContainerService.readFile('package.json'); 118 | name = JSON.parse(pkgContent).name; 119 | } 120 | 121 | await this.fileSaverService.uploadToGist(name, { 122 | editId: this.editId, 123 | }); 124 | this.snackBar.open( 125 | 'File uploaded ! Please note that this is a async operation. It may take Gist some time to return the newest list.', 126 | 'Close', 127 | { 128 | duration: 8000, 129 | } 130 | ); 131 | this.isSaving.set(false); 132 | } catch (error) { 133 | this.isSaving.set(false); 134 | this.snackBar.open('File upload failed. Please try again.', 'Close', { 135 | duration: 3000, 136 | }); 137 | console.log('error', error); 138 | } 139 | } 140 | 141 | handleProjectNameInput($event: Event) { 142 | const target = $event.target as HTMLInputElement; 143 | this.projectName.set(target.value); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/app/editor/features/main/components/resizer/resize-container.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | :host-context(.row) { 8 | flex-direction: row; 9 | } 10 | 11 | :host-context(.col) { 12 | flex-direction: column; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/editor/features/main/components/resizer/resize-container.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | ContentChildren, 5 | ElementRef, 6 | Input, 7 | QueryList, 8 | SimpleChanges, 9 | inject, 10 | OnChanges, 11 | AfterContentInit, 12 | } from '@angular/core'; 13 | import { ResizeManagerService } from './resize-manager.service'; 14 | import { ResizerComponent } from './resizer'; 15 | 16 | @Component({ 17 | standalone: true, 18 | selector: 'app-resizer-container', 19 | template: '', 20 | styleUrls: ['./resize-container.scss'], 21 | providers: [ResizeManagerService], 22 | changeDetection: ChangeDetectionStrategy.OnPush, 23 | }) 24 | export class ResizerContainerComponent implements OnChanges, AfterContentInit { 25 | @Input() direction: 'col' | 'row' = 'row'; 26 | @ContentChildren(ResizerComponent) resizers!: QueryList; 27 | 28 | private el = inject(ElementRef); 29 | private resizeService = inject(ResizeManagerService); 30 | 31 | ngOnChanges(changes: SimpleChanges) { 32 | if (changes['direction']) { 33 | this.resizeService.setDirection(this.direction); 34 | } 35 | } 36 | 37 | ngAfterContentInit() { 38 | const resizerArray = this.resizers.toArray(); 39 | // setFirst to get childIndex set for later resize 40 | this.resizeService.setResizelist(resizerArray.map(resizer => resizer.getOptions())); 41 | 42 | const initialRect = this.el.nativeElement.getBoundingClientRect(); 43 | this.resizeService.setContainerSize({ 44 | width: initialRect.right - initialRect.left, 45 | height: initialRect.bottom - initialRect.top, 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/editor/features/main/components/resizer/resize-manager.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ResizerComponent } from './resizer'; 3 | 4 | /* 5 | * just found this package after implementation: https://www.npmjs.com/package/angular-split 6 | */ 7 | 8 | interface ResizeItem { 9 | id: string; 10 | minSize: number; 11 | maxSize: number; 12 | width: number; 13 | height: number; 14 | resizer: ResizerComponent; 15 | percentage: number; 16 | } 17 | 18 | type Direction = 'col' | 'row'; 19 | 20 | @Injectable() 21 | export class ResizeManagerService { 22 | direction: Direction = 'row'; 23 | resizeList: ResizeItem[] = []; 24 | containerSize: { width: number; height: number } = { width: 0, height: 0 }; 25 | 26 | setDirection(dir: Direction) { 27 | this.direction = dir; 28 | } 29 | 30 | setContainerSize({ width, height }: { width: number; height: number }) { 31 | this.containerSize = { width, height }; 32 | } 33 | 34 | setResizelist(list: ResizeItem[]) { 35 | this.resizeList = list; 36 | 37 | let percentage = 0; 38 | this.resizeList.forEach(item => { 39 | percentage += item.percentage; 40 | if (percentage > 100) { 41 | throw new Error('resizer percentage sum > 100'); 42 | } 43 | item.width = this.direction === 'row' ? item.percentage : 100; 44 | item.height = this.direction === 'col' ? item.percentage : 100; 45 | item.resizer.updateSize('width', item.width); 46 | item.resizer.updateSize('height', item.height); 47 | }); 48 | } 49 | 50 | calculateNewSize({ id, delta: _delta }: { id: string; delta: number }) { 51 | const delta = (_delta / this.containerSize[this.getSizeFlagFromDir()]) * 100; 52 | 53 | const index = this.resizeList.findIndex(i => i.id === id); 54 | 55 | if (index === -1) { 56 | return; 57 | } 58 | 59 | const sizeFlag = getSizeFlagFromDir(this.direction); 60 | 61 | const preEle = this.resizeList[index - 1]; 62 | const nextEle = this.resizeList[index]; // firstElement does not have resize-bar 63 | 64 | const newSizeOfPrev = preEle[sizeFlag] + delta; 65 | const newSizeOfNext = nextEle[sizeFlag] - delta; 66 | 67 | if (newSizeOfPrev < preEle.minSize / preEle[sizeFlag] || newSizeOfNext < nextEle.minSize / nextEle[sizeFlag]) { 68 | return; 69 | } 70 | 71 | this.resizeList.forEach(item => { 72 | if (item.id === id) { 73 | item[sizeFlag] = newSizeOfNext; 74 | } else if (item.id === preEle.id) { 75 | item[sizeFlag] = newSizeOfPrev; 76 | } 77 | }); 78 | this.resizeList.forEach(item => { 79 | item.resizer.updateSize(sizeFlag, item[sizeFlag]); 80 | }); 81 | 82 | function getSizeFlagFromDir(dir: Direction) { 83 | return dir === 'row' ? 'width' : 'height'; 84 | } 85 | } 86 | getSizeFlagFromDir() { 87 | return this.direction === 'row' ? 'width' : 'height'; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/editor/features/main/components/resizer/resizer.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | position: relative; 4 | flex-grow: 1; 5 | } 6 | 7 | :host-context(.row) { 8 | flex-direction: row; 9 | } 10 | 11 | :host-context(.col) { 12 | flex-direction: column; 13 | } 14 | 15 | .resizer-bar { 16 | opacity: 0; 17 | &:hover { 18 | background-color: rebeccapurple; 19 | opacity: 1; 20 | // transition: opacity 0.1s ease-out; 21 | } 22 | } 23 | 24 | .resizer-bar.row { 25 | cursor: col-resize; 26 | width: 5px; 27 | margin: 0px -1.5px; 28 | height: 100%; 29 | } 30 | 31 | .resizer-bar.col { 32 | width: 100%; 33 | height: 5px; 34 | margin: -1.5px 0px; 35 | cursor: row-resize; 36 | } 37 | -------------------------------------------------------------------------------- /src/app/editor/features/main/components/resizer/resizer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ElementRef, 4 | Renderer2, 5 | AfterViewInit, 6 | Input, 7 | inject, 8 | ChangeDetectionStrategy, 9 | OnDestroy, 10 | } from '@angular/core'; 11 | import { ResizeManagerService } from './resize-manager.service'; 12 | 13 | interface IPosition { 14 | x: number; 15 | y: number; 16 | } 17 | 18 | @Component({ 19 | selector: 'app-resizer', 20 | standalone: true, 21 | template: ` 22 | @if (!isFirstElement) { 23 |
28 | } 29 | 30 | `, 31 | styleUrls: ['./resizer.scss'], 32 | imports: [], 33 | changeDetection: ChangeDetectionStrategy.OnPush, 34 | }) 35 | export class ResizerComponent implements AfterViewInit, OnDestroy { 36 | @Input() minSize = 0; 37 | @Input() maxSize = Infinity; 38 | @Input() isFirstElement = false; 39 | @Input() percentage = 0; 40 | private pointerDownPosition: IPosition | null = null; 41 | resizeService = inject(ResizeManagerService); 42 | id = `resizer-${Math.random()}`; 43 | 44 | private el = inject(ElementRef); 45 | private renderer = inject(Renderer2); 46 | 47 | interStyle = { 48 | width: 100, 49 | height: 100, 50 | }; 51 | 52 | ngAfterViewInit() { 53 | this.initEvents(); 54 | } 55 | 56 | ngOnDestroy() { 57 | this.stopResize(); 58 | } 59 | 60 | initEvents() { 61 | this.renderer.listen(this.el.nativeElement, 'pointerdown', this.startResize); 62 | } 63 | 64 | cleanupEvents() { 65 | document.removeEventListener('pointermove', this.handleResize); 66 | document.removeEventListener('pointerup', this.stopResize); 67 | } 68 | 69 | startResize = (e: PointerEvent) => { 70 | const resizerBar = this.el.nativeElement.querySelector('.resizer-bar'); 71 | if (resizerBar && resizerBar.contains(e.target as Node)) { 72 | // remember to stop only when needed 73 | this.stopEvent(e); 74 | this.pointerDownPosition = { 75 | x: e.clientX, 76 | y: e.clientY, 77 | }; 78 | document.addEventListener('pointermove', this.handleResize); 79 | document.addEventListener('pointerup', this.stopResize); 80 | 81 | // disable selection 82 | const style = document.createElement('style'); 83 | style.type = 'text/css'; 84 | style.id = 'disable-select'; 85 | style.innerHTML = ` 86 | * { 87 | user-select: none !important; 88 | pointer-events: none !important; 89 | cursor: ${this.resizeService.direction === 'row' ? 'col-resize' : 'row-resize'} !important; 90 | } 91 | `; 92 | document.head.appendChild(style); 93 | } else { 94 | this.stopResize(); 95 | } 96 | }; 97 | 98 | handleResize = (e: PointerEvent) => { 99 | this.stopEvent(e); 100 | requestAnimationFrame(() => { 101 | if (!this.pointerDownPosition) return; 102 | const direction = this.resizeService.direction; 103 | const { clientX, clientY } = e; 104 | let delta = 0; 105 | if (direction === 'row') { 106 | delta = clientX - this.pointerDownPosition!.x; 107 | } else { 108 | delta = clientY - this.pointerDownPosition!.y; 109 | } 110 | // update pointer location 111 | this.pointerDownPosition = { 112 | x: clientX, 113 | y: clientY, 114 | }; 115 | 116 | if (delta === 0) return; 117 | 118 | this.resizeService.calculateNewSize({ 119 | id: this.id, 120 | delta: delta, 121 | }); 122 | }); 123 | }; 124 | 125 | stopResize = () => { 126 | this.cleanupEvents(); 127 | this.pointerDownPosition = null; 128 | 129 | // enable selection 130 | const style = document.getElementById('disable-select'); 131 | if (style) { 132 | style.parentNode?.removeChild(style); 133 | } 134 | 135 | document.body.style.cursor = ''; 136 | }; 137 | 138 | stopEvent(e: Event) { 139 | e.preventDefault(); 140 | e.stopPropagation(); 141 | e.stopImmediatePropagation(); 142 | } 143 | 144 | getOptions() { 145 | return { 146 | id: this.id, 147 | minSize: this.minSize, 148 | maxSize: this.maxSize, 149 | width: 0, 150 | height: 0, 151 | percentage: this.percentage, 152 | originPercentage: this.percentage, 153 | resizer: this, 154 | }; 155 | } 156 | 157 | updateSize(sizeFlag: 'width' | 'height', size: number) { 158 | this.renderer.setStyle(this.el.nativeElement, sizeFlag, `${size}%`); 159 | 160 | this.interStyle[sizeFlag] = size; 161 | 162 | if (this.interStyle.width === 0 || this.interStyle.height === 0) { 163 | this.renderer.setStyle(this.el.nativeElement, 'display', 'none'); 164 | } else { 165 | this.renderer.setStyle(this.el.nativeElement, 'display', 'flex'); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/app/editor/features/main/edit/code-editor/code-editor.component.scss: -------------------------------------------------------------------------------- 1 | .editor-container { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/editor/features/main/edit/code-editor/code-editor.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | Component, 4 | ElementRef, 5 | EventEmitter, 6 | forwardRef, 7 | HostBinding, 8 | Input, 9 | OnDestroy, 10 | Output, 11 | Renderer2, 12 | ViewChild, 13 | inject, 14 | ChangeDetectionStrategy, 15 | } from '@angular/core'; 16 | import { NG_VALUE_ACCESSOR } from '@angular/forms'; 17 | import { debounceTime, fromEvent, Subject, takeUntil } from 'rxjs'; 18 | import { CodeEditorService } from './code-editor.service'; 19 | 20 | export interface EditorModel { 21 | content: string; 22 | language?: string; 23 | uri?: string; 24 | } 25 | 26 | @Component({ 27 | standalone: true, 28 | selector: 'app-monaco-editor', 29 | template: `
`, 30 | styleUrls: ['./code-editor.component.scss'], 31 | imports: [], 32 | changeDetection: ChangeDetectionStrategy.OnPush, 33 | providers: [ 34 | { 35 | provide: NG_VALUE_ACCESSOR, 36 | useExisting: forwardRef(() => AppEditorComponent), 37 | multi: true, 38 | }, 39 | ], 40 | }) 41 | export class AppEditorComponent implements AfterViewInit, OnDestroy { 42 | @ViewChild('editorContainer') editorContentRef!: ElementRef; 43 | @Input() @HostBinding('style.height') height = '100%'; 44 | 45 | @Output() modelChange = new EventEmitter(); 46 | 47 | private destroyRef$: Subject = new Subject(); 48 | private editor: monaco.editor.IStandaloneCodeEditor | undefined; 49 | 50 | private disposables: monaco.IDisposable[] = []; 51 | 52 | options: monaco.editor.IStandaloneEditorConstructionOptions = { 53 | theme: 'vs-dark', 54 | language: 'javascript', 55 | fontSize: 16, 56 | wordWrap: 'on', 57 | automaticLayout: true, 58 | minimap: { 59 | enabled: false, 60 | }, 61 | }; 62 | 63 | codeEditorService = inject(CodeEditorService); 64 | renderer = inject(Renderer2); 65 | 66 | ngAfterViewInit(): void { 67 | this.codeEditorService 68 | .getScriptLoadSubject() 69 | .pipe(takeUntil(this.destroyRef$)) 70 | .subscribe(isLoaded => { 71 | if (isLoaded) { 72 | this.initMonaco(); 73 | } 74 | }); 75 | 76 | fromEvent(window, 'resize') 77 | .pipe(debounceTime(50), takeUntil(this.destroyRef$)) 78 | .subscribe(() => { 79 | if (this.editor) { 80 | this.editor.layout(); 81 | } 82 | }); 83 | } 84 | 85 | private initMonaco(): void { 86 | const options = this.options; 87 | const editorWrapper: HTMLDivElement = this.editorContentRef.nativeElement; 88 | 89 | if (!this.editor) { 90 | this.editor = this.codeEditorService.initEditor(editorWrapper, options); 91 | } 92 | this.renderer.setStyle(this.editorContentRef.nativeElement, 'height', this.height); 93 | 94 | this.editor.layout(); 95 | } 96 | 97 | ngOnDestroy(): void { 98 | this.destroyRef$.next(); 99 | this.destroyRef$.complete(); 100 | if (this.editor) { 101 | this.editor.dispose(); 102 | this.editor = undefined; 103 | } 104 | if (this.disposables.length) { 105 | this.disposables.forEach(disposable => disposable.dispose()); 106 | this.disposables = []; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/app/editor/features/main/edit/code-editor/deps-parsre.service.ts: -------------------------------------------------------------------------------- 1 | export enum ImportKind { 2 | Package = 'package', 3 | Relative = 'relative', 4 | Alias = 'alias', 5 | Reference = 'reference', 6 | } 7 | 8 | export interface ImportResult { 9 | kind: ImportKind; 10 | path: string; 11 | } 12 | 13 | export function parseImports(content: string) { 14 | const results: ImportResult[] = []; 15 | 16 | const packageImportRegex = /import\s.*\sfrom\s['"]([^'"]+)['"]/g; 17 | const commonJSImportRegex = /const\s.*=\srequire\(['"]([^'"]+)['"]/g; 18 | const referencePathRegex = /\/\/\/\s // TypeScript reference path 52 | 53 | // Relative imports 54 | import { LocalModule } from './local-module'; 55 | import { LocalModuleInDir } from '../dir/local-module-in-dir'; 56 | 57 | // TypeScript path alias imports 58 | import { AliasModule } from '@alias/alias-module'; 59 | import { AnotherAliasModule } from '@another-alias/another-alias-module'; 60 | 61 | // Sass file import 62 | import '../styles/main.scss'; 63 | `; 64 | return source; 65 | } 66 | -------------------------------------------------------------------------------- /src/app/editor/features/main/edit/code-editor/prettier.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { BehaviorSubject, Observable } from 'rxjs'; 3 | 4 | declare const window: any; 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class PrettierService { 10 | private prettier: any; 11 | private prettierPlugins: any; 12 | private isLoaded = new BehaviorSubject(false); 13 | 14 | loadPrettier(): Observable { 15 | if (this.isLoaded.value) { 16 | return this.isLoaded.asObservable(); 17 | } 18 | 19 | window.define( 20 | 'Prettier', 21 | [ 22 | '/assets/prettier/standalone.js', 23 | '/assets/prettier/parser-babel.js', 24 | '/assets/prettier/parser-html.js', 25 | '/assets/prettier/parser-postcss.js', 26 | '/assets/prettier/parser-typescript.js', 27 | ], 28 | (Prettier: any, ...args: any[]) => { 29 | this.prettier = Prettier; 30 | this.prettierPlugins = { 31 | babel: args[0], 32 | html: args[1], 33 | postcss: args[2], 34 | typescript: args[3], 35 | }; 36 | this.isLoaded.next(true); 37 | } 38 | ); 39 | 40 | return this.isLoaded.asObservable(); 41 | } 42 | 43 | format(code: string, filepath: string): string { 44 | if (!this.prettier || !this.prettierPlugins) { 45 | console.error('Prettier or PrettierPlugins not loaded'); 46 | return code; 47 | } 48 | 49 | const options = { 50 | filepath, 51 | plugins: this.prettierPlugins, 52 | singleQuote: true, 53 | tabWidth: 2, 54 | useTabs: false, 55 | semi: true, 56 | bracketSpacing: true, 57 | arrowParens: 'always', 58 | trailingComma: 'es5', 59 | printWidth: 80, 60 | }; 61 | 62 | try { 63 | return this.prettier.format(code, options); 64 | } catch (error) { 65 | console.error('Prettier formatting error:', error); 66 | return code; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/app/editor/features/main/edit/edit.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | @for (tab of editService.openedTabs(); track tab.filePath) { 4 |
5 |
6 | {{ tab.name }} 7 |
8 | @if (tab.isPendingWrite) { 9 |
10 |
11 |
12 | } @else { 13 |
14 | } 15 |
16 | } 17 |
18 |
19 | 20 | @if (!isPreviewOpen()) { 21 | keyboard_tab 22 | } 23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /src/app/editor/features/main/edit/edit.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | .top { 7 | border: 1px solid #fff; 8 | border-top: none; 9 | display: flex; 10 | justify-content: space-between; 11 | align-items: center; 12 | .tabs { 13 | display: flex; 14 | height: 30px; 15 | align-items: center; 16 | overflow: hidden; 17 | background-color: var(--sb-page-background); 18 | .tab-item { 19 | user-select: none; 20 | position: relative; 21 | display: inline-flex; 22 | align-items: center; 23 | justify-content: center; 24 | background-color: var(--tab-inactiveBackground); 25 | color: var(--sb-foreground-alt); 26 | flex-shrink: 0; 27 | font-size: 13px; 28 | font-weight: 400; 29 | 30 | padding: 0; 31 | padding-inline-end: 4px; 32 | padding-inline-start: 10px; 33 | cursor: pointer; 34 | 35 | &.active { 36 | background-color: var(--tab-activeBackground); 37 | } 38 | .text { 39 | min-width: 60px; 40 | max-width: 100px; 41 | white-space: nowrap; 42 | overflow: hidden; 43 | text-overflow: ellipsis; 44 | } 45 | 46 | .close, 47 | .pending { 48 | width: 20px; 49 | padding: 2px; 50 | text-align: center; 51 | cursor: pointer; 52 | &:hover { 53 | opacity: 0.8; 54 | } 55 | } 56 | .pending { 57 | display: flex; 58 | align-items: center; 59 | justify-content: center; 60 | .dot { 61 | width: 8px; 62 | height: 8px; 63 | border-radius: 50%; 64 | background-color: #fff; 65 | } 66 | } 67 | } 68 | } 69 | .functions { 70 | display: flex; 71 | align-items: center; 72 | margin-left: 4px; 73 | margin-right: 4px; 74 | .prettier { 75 | margin-right: 4px; 76 | font-size: 18px; 77 | width: 18px; 78 | height: 18px; 79 | cursor: pointer; 80 | } 81 | .open-preview { 82 | cursor: pointer; 83 | transform: rotate(180deg); 84 | flex-grow: 0; 85 | flex-shrink: 0; 86 | } 87 | } 88 | } 89 | .bottom { 90 | flex: 1; 91 | position: relative; 92 | overflow: hidden; 93 | background-color: #212121; 94 | } 95 | -------------------------------------------------------------------------------- /src/app/editor/features/main/edit/edit.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ViewChild, OnInit, OnDestroy, inject, computed } from '@angular/core'; 2 | import { AppEditorComponent } from './code-editor/code-editor.component'; 3 | import { EditService, ITabItem } from './edit.service'; 4 | import { MatIcon } from '@angular/material/icon'; 5 | import { MainService } from '../main.service'; 6 | import { CodeEditorService } from './code-editor/code-editor.service'; 7 | 8 | @Component({ 9 | standalone: true, 10 | selector: 'app-edit', 11 | imports: [AppEditorComponent, MatIcon], 12 | templateUrl: './edit.component.html', 13 | styleUrls: ['./edit.component.scss'], 14 | changeDetection: ChangeDetectionStrategy.OnPush, 15 | }) 16 | export class EditComponent implements OnInit, OnDestroy { 17 | @ViewChild(AppEditorComponent) editorComponent: AppEditorComponent | undefined; 18 | 19 | editService = inject(EditService); 20 | codeEditorService = inject(CodeEditorService); 21 | private mainService = inject(MainService); 22 | 23 | isPreviewOpen = computed(() => this.mainService.isPreviewOpen()); 24 | 25 | constructor() {} 26 | 27 | ngOnInit() { 28 | document.addEventListener('keydown', this.handleKeyDown.bind(this)); 29 | 30 | this.editService.initEvents(); 31 | } 32 | 33 | ngOnDestroy() { 34 | document.removeEventListener('keydown', this.handleKeyDown.bind(this)); 35 | } 36 | 37 | async handleKeyDown(event: KeyboardEvent) { 38 | if ((event.ctrlKey || event.metaKey) && event.key === 's') { 39 | event.preventDefault(); 40 | await this.editService.saveFile(); 41 | } 42 | } 43 | 44 | updateTabs(filePath: string) { 45 | this.editService.updateTabs(filePath); 46 | } 47 | 48 | selectTab(tab: ITabItem) { 49 | this.editService.selectTab(tab); 50 | } 51 | 52 | closeTab(tabItem: ITabItem, event: Event) { 53 | event.stopPropagation(); 54 | this.editService.closeTab(tabItem.filePath, true); 55 | } 56 | openPreview() { 57 | this.mainService.openPreview(); 58 | } 59 | formatCurrentFile() { 60 | this.codeEditorService.formatCurrentFile(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/editor/features/main/edit/edit.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | import { NodeContainerService } from '@app/editor/services/node-container.service'; 3 | import { EditorStateService } from '@app/editor/services/editor-state.service'; 4 | import { CodeEditorService } from './code-editor/code-editor.service'; 5 | import { isFile } from '@app/editor/utils/file'; 6 | import { WritableSignal, signal, effect } from '@angular/core'; 7 | 8 | export interface ITabItem { 9 | filePath: string; 10 | name: string; 11 | active: boolean; 12 | isPendingWrite: boolean; 13 | } 14 | 15 | @Injectable({ 16 | providedIn: 'root', 17 | }) 18 | export class EditService { 19 | private readonly nodeContainerService = inject(NodeContainerService); 20 | private readonly editorStateService = inject(EditorStateService); 21 | private readonly codeEditorService = inject(CodeEditorService); 22 | 23 | openedTabs: WritableSignal = signal([]); 24 | 25 | constructor() { 26 | effect(async () => { 27 | const currentFilePath = this.editorStateService.geCurrentFilePath(); 28 | const fileTree = this.editorStateService.getFileTree(); 29 | if (currentFilePath && fileTree && isFile(fileTree, currentFilePath)) { 30 | try { 31 | const content = await this.nodeContainerService.readFile(currentFilePath); 32 | this.updateTabs(currentFilePath); 33 | if (content !== undefined) { 34 | this.codeEditorService.openOrCreateFile({ 35 | content, 36 | filePath: currentFilePath, 37 | }); 38 | } 39 | } catch (error) { 40 | console.log('error', error); 41 | } 42 | } 43 | }); 44 | } 45 | 46 | async updateFileModels(filePaths: string[]) { 47 | await this.codeEditorService.ensureMonacoLoaded(); 48 | 49 | filePaths.forEach(async filePath => { 50 | if (!this.codeEditorService.isModelExist(filePath)) { 51 | try { 52 | const content = await this.nodeContainerService.readFile(filePath); 53 | if (content) { 54 | this.codeEditorService.openOrCreateFile({ 55 | filePath, 56 | content, 57 | }); 58 | } else { 59 | console.log(`error: content of ${filePath} not exists`); 60 | } 61 | } catch (error) { 62 | console.log('updateFileModels error:', error); 63 | } 64 | } 65 | }); 66 | } 67 | 68 | initEvents() { 69 | this.codeEditorService.on('contentChanged', ({ content, filePath }) => { 70 | this.openedTabs.update(tabs => 71 | tabs.map(t => ({ 72 | ...t, 73 | isPendingWrite: t.filePath === filePath ? true : t.isPendingWrite, 74 | })) 75 | ); 76 | }); 77 | this.codeEditorService.on('goToDefinition', ({ filePath }) => { 78 | this.editorStateService.setCurrentFilePath(filePath); 79 | }); 80 | } 81 | 82 | updateTabs(filePath: string) { 83 | const isTabExist = this.openedTabs().find(t => t.filePath === filePath); 84 | if (!isTabExist) { 85 | const newTab = { 86 | filePath: filePath, 87 | name: this.extractFileName(filePath), 88 | isPendingWrite: false, 89 | active: true, 90 | }; 91 | this.openedTabs.update(tabs => [ 92 | ...tabs.map(t => ({ 93 | ...t, 94 | active: false, 95 | })), 96 | newTab, 97 | ]); 98 | } else { 99 | this.openedTabs.update(tabs => 100 | tabs.map(t => ({ 101 | ...t, 102 | active: t.filePath === filePath, 103 | })) 104 | ); 105 | } 106 | } 107 | 108 | selectTab(tab: ITabItem) { 109 | this.editorStateService.setCurrentFilePath(tab.filePath); 110 | } 111 | 112 | closeTab(filePath: string, preserveModel = false) { 113 | const openedTabs = this.openedTabs().slice(); 114 | const findIndex = openedTabs.findIndex(t => t.filePath === filePath); 115 | 116 | if (findIndex === -1) { 117 | // If the specified tab is not found, return directly 118 | return; 119 | } 120 | 121 | // editor close model to release memory, update this when rename or delete file 122 | if (!preserveModel) { 123 | this.codeEditorService.closeFile(filePath); 124 | } 125 | 126 | const newTabs = openedTabs.filter(t => t.filePath !== filePath); 127 | this.openedTabs.set(newTabs); 128 | 129 | if (newTabs.length === 0) { 130 | // If there are no open tabs, set the current file path to an empty string 131 | this.editorStateService.setCurrentFilePath(''); 132 | } else { 133 | // Calculate the new active tab index, focusing on the previous tab if possible 134 | const newActiveIndex = findIndex > 0 ? findIndex - 1 : 0; 135 | this.editorStateService.setCurrentFilePath(newTabs[newActiveIndex].filePath); 136 | } 137 | } 138 | 139 | async saveFile() { 140 | const filePath = this.editorStateService.geCurrentFilePath(); 141 | if (filePath) { 142 | const content = this.codeEditorService.getCurrentFileContent(); 143 | if (content) { 144 | this.nodeContainerService.writeFile(filePath, content); 145 | 146 | this.openedTabs.update(tabs => 147 | tabs.map(t => ({ 148 | ...t, 149 | isPendingWrite: t.filePath === filePath ? false : t.isPendingWrite, 150 | })) 151 | ); 152 | } 153 | } 154 | } 155 | 156 | extractFileName(filePath: string): string { 157 | const match = filePath.match(/[^/\\]+$/); 158 | 159 | if (match) { 160 | return match[0]; 161 | } 162 | 163 | return ''; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/app/editor/features/main/main.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | sidebar 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /src/app/editor/features/main/main.component.scss: -------------------------------------------------------------------------------- 1 | $header-height: 40px; 2 | :host { 3 | height: calc(100vh - $header-height); 4 | flex: 1; 5 | display: flex; 6 | position: relative; 7 | border-bottom: 1px solid #fff; 8 | 9 | #sidebar, 10 | #edit, 11 | #output { 12 | height: 100%; 13 | overflow: auto; 14 | flex-grow: 1; 15 | } 16 | 17 | #sidebar { 18 | position: relative; 19 | } 20 | 21 | #edit { 22 | height: 100%; 23 | } 24 | 25 | #output { 26 | flex: 1; 27 | height: 100%; 28 | position: relative; 29 | overflow: hidden; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/editor/features/main/main.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, ChangeDetectionStrategy, Component, inject, ViewChild } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { EditComponent } from './edit/edit.component'; 4 | import { ResizerContainerComponent } from './components/resizer/resize-container'; 5 | import { ResizerComponent } from './components/resizer/resizer'; 6 | import { PreviewComponent } from './ouput/preview/preview.component'; 7 | import { TerminalComponent } from './ouput/terminal/terminal.component'; 8 | import { SidebarComponent } from './sidebar/sidebar.component'; 9 | import { MainService } from './main.service'; 10 | import { ConsoleComponent } from './ouput/console/console.component'; 11 | 12 | @Component({ 13 | selector: 'app-editor-main', 14 | standalone: true, 15 | imports: [ 16 | CommonModule, 17 | EditComponent, 18 | ResizerContainerComponent, 19 | ResizerComponent, 20 | PreviewComponent, 21 | TerminalComponent, 22 | SidebarComponent, 23 | ConsoleComponent, 24 | ], 25 | providers: [], 26 | templateUrl: './main.component.html', 27 | styleUrl: './main.component.scss', 28 | changeDetection: ChangeDetectionStrategy.OnPush, 29 | }) 30 | export class MainComponent implements AfterViewInit { 31 | @ViewChild('mainResizer') mainResizer!: ResizerComponent; 32 | @ViewChild('outputResizer') outputResizer!: ResizerComponent; 33 | @ViewChild('editResizer') editResizer!: ResizerComponent; 34 | mainService = inject(MainService); 35 | 36 | ngAfterViewInit() { 37 | this.mainService.setMainResizer(this.mainResizer); 38 | this.mainService.setOutputResizer(this.outputResizer); 39 | this.mainService.setEditResizer(this.editResizer); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/editor/features/main/ouput/console/_console-script.js: -------------------------------------------------------------------------------- 1 | // This file is auto-generated, please do not modify by hand 2 | export const proxyConsoleScript = `"use strict";function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _slicedToArray(e,r){return _arrayWithHoles(e)||_iterableToArrayLimit(e,r)||_unsupportedIterableToArray(e,r)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(e,r){if(e){if("string"==typeof e)return _arrayLikeToArray(e,r);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array\$/.test(n)?_arrayLikeToArray(e,r):void 0}}function _arrayLikeToArray(e,r){(null==r||r>e.length)&&(r=e.length);for(var n=0,t=Array(r);n 2 |
3 | Console 4 |
5 |
6 | delete 7 | {{ 8 | mainService.isConsoleOpen() ? 'expand_less' : 'expand_more' 9 | }} 10 |
11 | 12 |
13 |
14 | @for (log of consoleService.logs(); track log.args) { 15 |
16 | 17 | @for (content of log.args; track content.value) { 18 | @if (['array', 'object', 'set', 'map'].indexOf(content.type) > -1) { 19 | 20 | } @else { 21 | 22 | } 23 | } 24 |
25 | } 26 |
27 | @if (consoleService.errors() && consoleService.errors().length) { 28 |
29 | @for (error of consoleService.errors(); track error.message) { 30 |
31 | 32 |
33 |
{{ error.message }}
34 |
35 | @if (error.stacks && error.stacks.length) { 36 | @for (s of error.stacks; track s) { 37 |
{{ s }}
38 | } 39 | } 40 |
41 |
42 |
43 | } 44 |
45 | } 46 |
47 |
48 | navigate_next 49 | 56 |
57 | -------------------------------------------------------------------------------- /src/app/editor/features/main/ouput/console/console.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | background-color: #030301; 6 | 7 | --bg-warn: #fef6d5; 8 | --bg-error: #faaaa3; 9 | } 10 | 11 | .controls { 12 | border-bottom: 1px solid #fff; 13 | padding: 0 4px; 14 | height: 20px; 15 | display: flex; 16 | align-items: center; 17 | justify-content: space-between; 18 | .left { 19 | .title { 20 | font-weight: bold; 21 | font-size: 13px; 22 | } 23 | } 24 | .right { 25 | display: flex; 26 | align-items: center; 27 | .clear-btn { 28 | font-size: 16px; 29 | width: 16px; 30 | height: 16px; 31 | } 32 | .toggle-btn { 33 | } 34 | } 35 | } 36 | 37 | .command-line { 38 | display: flex; 39 | align-items: center; 40 | height: 24px; 41 | .icon { 42 | } 43 | .input { 44 | flex: 1; 45 | outline: none; 46 | border: none; 47 | resize: none; 48 | outline: none; 49 | background: inherit; 50 | color: #fff; 51 | caret-color: #fff; 52 | } 53 | } 54 | 55 | .console-wrap { 56 | flex: 1; 57 | overflow: auto; 58 | .console-entries { 59 | } 60 | 61 | .console-item, 62 | .error-item { 63 | padding: 0 6px; 64 | border-bottom: 1px solid #ccc; 65 | font-size: 13px; 66 | display: flex; 67 | align-items: center; 68 | 69 | .icon { 70 | width: 10px; 71 | height: 18px; 72 | background-repeat: no-repeat; 73 | background-position: 50% 50%; 74 | display: inline-block; 75 | margin-right: 6px; 76 | display: none; 77 | } 78 | } 79 | 80 | .console-item { 81 | &.info { 82 | } 83 | &.debug { 84 | } 85 | &.warn { 86 | background-color: var(--bg-warn); 87 | color: #000; 88 | .icon { 89 | display: block; 90 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACkSURBVChTbY7BCoJQFERn0Q/3BX1JuxQjsSCXiV8gtCgxhCIrKIRIqKDVzXl5w5cNHBjm6eGinXiAXu5inY2xYm/mbpIh+vcFhLA3sx0athNUhymEsP+10lAEEA17x8o/9wFuNGnYuVlWve0SQl7P0sBu3aq2R1Q/1JzSkYGd29eqNv2wjdnUuvNRciC/N+qe+7gidbA8zyHkOINsvA/sumcOkjcabcBmw2+mMgAAAABJRU5ErkJggg=="); 91 | } 92 | } 93 | 94 | &.error { 95 | background-color: var(--bg-error); 96 | color: #000; 97 | .icon { 98 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADESURBVChTY4CB7ZI8tmfU5E6e01b+DMIgNkgMKg0BR9Vkux6YWPx/bemIgkFiIDmwogOaqrYPzazAEm8DwuGKYGyQHEgNw0VT05Mwib9v3v7/kJEHxiA2TDFIDcNNU4vPMFPACj58/P/v40cwGyYOUsNwy8IZRSFIEUgxskKQGoZrzp4ErQapYbgYHG371M4dLACTQGaD5EBqwD6/FpzQ9dTBE64IhkFiIDmwIhi4mlJqey8o4eR9r8jPIAxig8QgsgwMAFZz1YtGPXgjAAAAAElFTkSuQmCC"); 99 | display: block; 100 | } 101 | } 102 | } 103 | .error-item { 104 | background-color: var(--bg-error); 105 | color: #000; 106 | display: flex; 107 | align-items: center; 108 | .icon { 109 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADESURBVChTY4CB7ZI8tmfU5E6e01b+DMIgNkgMKg0BR9Vkux6YWPx/bemIgkFiIDmwogOaqrYPzazAEm8DwuGKYGyQHEgNw0VT05Mwib9v3v7/kJEHxiA2TDFIDcNNU4vPMFPACj58/P/v40cwGyYOUsNwy8IZRSFIEUgxskKQGoZrzp4ErQapYbgYHG371M4dLACTQGaD5EBqwD6/FpzQ9dTBE64IhkFiIDmwIhi4mlJqey8o4eR9r8jPIAxig8QgsgwMAFZz1YtGPXgjAAAAAElFTkSuQmCC"); 110 | } 111 | .msg { 112 | .stacks { 113 | line-height: 16px; 114 | margin-left: 10px; 115 | font-size: 11px; 116 | .stack { 117 | font-size: 10px; 118 | } 119 | } 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/app/editor/features/main/ouput/console/console.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, effect, ElementRef, inject, ViewChild } from '@angular/core'; 2 | import { ConsoleService } from './console.service'; 3 | import { MatIcon } from '@angular/material/icon'; 4 | import { MainService } from '../../main.service'; 5 | import { CompoundObjRendererComponent } from './compound-obj-renderer'; 6 | import { CommonModule } from '@angular/common'; 7 | import { PrimitiveRendererComponent } from './primitive-renderer.component'; 8 | 9 | @Component({ 10 | selector: 'app-console', 11 | templateUrl: './console.component.html', 12 | styleUrls: ['./console.component.scss'], 13 | standalone: true, 14 | imports: [MatIcon, CommonModule, CompoundObjRendererComponent, PrimitiveRendererComponent], 15 | }) 16 | export class ConsoleComponent { 17 | @ViewChild('commandInput', { static: true }) 18 | commandInput!: ElementRef; 19 | @ViewChild('consoleWrap', { static: true }) 20 | consoleWrap!: ElementRef; 21 | consoleService = inject(ConsoleService); 22 | mainService = inject(MainService); 23 | 24 | constructor() { 25 | window.addEventListener('message', this.consoleService.handleMessage.bind(this.consoleService)); 26 | 27 | effect(() => { 28 | if (this.mainService.isConsoleOpen()) { 29 | this.commandInput.nativeElement.focus(); 30 | } 31 | }); 32 | 33 | effect(() => { 34 | const logs = this.consoleService.logs(); 35 | const errors = this.consoleService.errors(); 36 | if (logs.length || errors.length) { 37 | this.consoleWrap.nativeElement.scrollTop = this.consoleWrap.nativeElement.scrollHeight; 38 | } 39 | }); 40 | } 41 | 42 | toggleConsole() { 43 | this.mainService.toggleConsole(); 44 | } 45 | 46 | clearConsole() { 47 | this.consoleService.clearConsole(); 48 | } 49 | 50 | executeCommand(event: Event) { 51 | event.preventDefault(); 52 | const textarea = this.commandInput.nativeElement; 53 | const code = textarea.value.trim(); 54 | 55 | if (code) { 56 | this.consoleService.addCommandToHistory(code); 57 | const previewIframe = document.querySelector('#preview-panel')?.querySelector('iframe'); 58 | if (previewIframe && previewIframe.contentWindow) { 59 | previewIframe.contentWindow.postMessage({ type: 'execute', code }, '*'); 60 | textarea.value = ''; 61 | } 62 | } 63 | 64 | textarea.focus(); 65 | } 66 | 67 | handleKeyUp(event: KeyboardEvent) { 68 | const textarea = this.commandInput.nativeElement; 69 | if (event.key === 'ArrowUp') { 70 | const previousCommand = this.consoleService.getPreviousCommand(); 71 | if (previousCommand !== null) { 72 | textarea.value = previousCommand; 73 | textarea.focus(); 74 | } 75 | } else if (event.key === 'ArrowDown') { 76 | const nextCommand = this.consoleService.getNextCommand(); 77 | if (nextCommand !== null) { 78 | textarea.value = nextCommand; 79 | textarea.focus(); 80 | } else { 81 | textarea.value = ''; 82 | } 83 | } 84 | } 85 | 86 | getItemType(type: string) { 87 | return type; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/editor/features/main/ouput/console/console.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, signal, WritableSignal } from '@angular/core'; 2 | 3 | export interface dataType { 4 | type: string; 5 | value: string; 6 | } 7 | 8 | interface IConsoleMessage { 9 | type: 'console'; 10 | method: 'log' | 'error' | 'warn' | 'info' | 'debug'; 11 | args: dataType[]; 12 | } 13 | 14 | interface IError { 15 | type: 'error'; 16 | message: string; 17 | codeInfo: string; 18 | stacks: string[]; 19 | } 20 | 21 | @Injectable({ 22 | providedIn: 'root', 23 | }) 24 | export class ConsoleService { 25 | logs: WritableSignal = signal([]); 26 | errors: WritableSignal = signal([]); 27 | commandHistory: string[] = []; 28 | historyIndex = -1; 29 | 30 | clearConsole() { 31 | this.logs.set([]); 32 | this.errors.set([]); 33 | this.commandHistory = []; 34 | this.historyIndex = -1; 35 | } 36 | 37 | addCommandToHistory(command: string) { 38 | this.commandHistory.push(command); 39 | this.historyIndex = this.commandHistory.length; 40 | } 41 | 42 | getPreviousCommand(): string | null { 43 | if (this.historyIndex > 0) { 44 | this.historyIndex--; 45 | return this.commandHistory[this.historyIndex]; 46 | } 47 | return null; 48 | } 49 | 50 | getNextCommand(): string | null { 51 | if (this.historyIndex === 0 && this.commandHistory.length === 1) { 52 | return this.commandHistory[0]; 53 | } 54 | if (this.historyIndex < this.commandHistory.length - 1) { 55 | this.historyIndex++; 56 | return this.commandHistory[this.historyIndex]; 57 | } 58 | return null; 59 | } 60 | 61 | handleMessage(event: MessageEvent) { 62 | if (!['webcontainer', 'localhost'].some(h => event.origin.includes(h))) { 63 | return; 64 | } 65 | 66 | const { type } = event.data; 67 | if (type === 'console') { 68 | const { method, args } = event.data; 69 | if (['log', 'error', 'warn', 'info', 'debug'].includes(method)) { 70 | this.logs.set([...this.logs(), { type, method, args }]); 71 | } 72 | } else if (type === 'error') { 73 | this.errors.set([...this.errors(), event.data]); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/app/editor/features/main/ouput/console/getProxyConsoleScript.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error skip 2 | import { proxyConsoleScript } from './_console-script.js'; 3 | /** 4 | * due to "CROS policy", we can't directly inject script to iframe 5 | * cuz the domain of iframe are not the same as the main 6 | * so we have to some how inject the script to the index.html 7 | * before the project loaded to webContainer 8 | */ 9 | function getProxyConsoleScript() { 10 | return ``; 15 | } 16 | 17 | function uint8ArrayToString(array: Uint8Array): string { 18 | return new TextDecoder().decode(array); 19 | } 20 | 21 | // rewrite html and inject script 22 | export function injectProxyScriptToEntryHTML(contents: string | Uint8Array) { 23 | const proxyScript = getProxyConsoleScript(); 24 | 25 | if (contents instanceof Uint8Array) { 26 | contents = uint8ArrayToString(contents); 27 | } 28 | 29 | // Inject script before the closing tag 30 | return contents.replace('', `${proxyScript}`); 31 | } 32 | 33 | /** 34 | * for simplicity, we assume there is only on index.html file 35 | * usually index.html , src/index.html or build/index.html 36 | * for other scenarios like webpack multi-page, we just don't handle that 37 | */ 38 | export function isEntryFile(path: string) { 39 | return path.endsWith('index.html'); 40 | } 41 | 42 | export function removeProxyScriptOfEntryHTML(contents: string | Uint8Array): string { 43 | if (contents instanceof Uint8Array) { 44 | contents = uint8ArrayToString(contents); 45 | } 46 | 47 | const proxyScriptRegex = /