├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── LICENSE.md ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── project.json ├── src ├── mocks │ ├── cursor-mock.js │ ├── desktop-mock.js │ ├── editor-application-mock.js │ ├── editor-line-mock.js │ ├── player-mock.js │ ├── styles-mock.js │ ├── terminal-application-mock.js │ ├── terminal-command-line-mock.js │ └── terminal-response-line-mock.js ├── scripts │ ├── components │ │ ├── application │ │ │ ├── application.html │ │ │ ├── application.js │ │ │ └── application.test.js │ │ ├── cursor │ │ │ ├── cursor.html │ │ │ ├── cursor.js │ │ │ └── cursor.test.js │ │ ├── desktop │ │ │ ├── desktop.html │ │ │ ├── desktop.js │ │ │ └── desktop.test.js │ │ ├── editor-application │ │ │ ├── editor-application.js │ │ │ └── editor-application.test.js │ │ ├── editor-line │ │ │ ├── editor-line.html │ │ │ ├── editor-line.js │ │ │ └── editor-line.test.js │ │ ├── player │ │ │ ├── player.js │ │ │ └── player.test.js │ │ ├── terminal-application │ │ │ ├── terminal-application.js │ │ │ └── terminal-application.test.js │ │ ├── terminal-command-line │ │ │ ├── terminal-command-line.html │ │ │ ├── terminal-command-line.js │ │ │ └── terminal-command-line.test.js │ │ ├── terminal-line │ │ │ ├── terminal-line.html │ │ │ ├── terminal-line.js │ │ │ └── terminal-line.test.js │ │ └── terminal-response-line │ │ │ ├── terminal-response-line.html │ │ │ ├── terminal-response-line.js │ │ │ └── terminal-response-line.test.js │ ├── constants │ │ └── application.js │ ├── index.js │ ├── index.test.js │ └── services │ │ ├── dom │ │ ├── dom.js │ │ └── dom.test.js │ │ ├── text │ │ ├── text.js │ │ └── text.test.js │ │ ├── type-html-text │ │ ├── type-html-text.js │ │ └── type-html-text.test.js │ │ ├── type-plain-text │ │ ├── type-plain-text.js │ │ └── type-plain-text.test.js │ │ └── type │ │ ├── type.js │ │ └── type.test.js └── styles │ ├── _mixins.styl │ ├── _variables.styl │ ├── application.styl │ ├── cursor.styl │ ├── desktop.styl │ ├── editor-application.styl │ ├── editor-line.styl │ ├── terminal-command-line.styl │ ├── terminal-line.styl │ └── terminal-response-line.styl ├── standalone └── index.html ├── webpack.conf.base.js ├── webpack.conf.dev.js ├── webpack.conf.prod.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"] 3 | } 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 3 | version: 2.1 4 | orbs: 5 | coveralls: coveralls/coveralls@1.0.6 6 | jobs: 7 | build: 8 | docker: 9 | - image: cimg/node:16.14.2-browsers 10 | 11 | steps: 12 | - checkout 13 | # Download and cache dependencies 14 | - restore_cache: 15 | keys: 16 | - v1-dependencies-{{ checksum "package.json" }} 17 | # fallback to using the latest cache if no exact match is found 18 | - v1-dependencies- 19 | 20 | - run: 21 | name: Dependencies 22 | command: npm install 23 | 24 | - save_cache: 25 | paths: 26 | - node_modules 27 | key: v1-dependencies-{{ checksum "package.json" }} 28 | 29 | - run: 30 | name: Format 31 | command: npm run format 32 | 33 | - run: 34 | name: Build 35 | command: npm run build -- --env=production 36 | 37 | - run: 38 | name: Test 39 | command: npm run test -- --maxWorkers=2 --coverage --coverageReporters=lcov 40 | - coveralls/upload: 41 | verbose: true 42 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jest": true, 6 | "node": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "ecmaVersion": 2018, 11 | "sourceType": "module" 12 | }, 13 | "globals": { 14 | "spyOn": true, 15 | "module": true 16 | }, 17 | "rules": { 18 | "indent": ["error", 2], 19 | "linebreak-style": ["error", "unix"], 20 | "quotes": ["error", "single"], 21 | "semi": ["error", "always"], 22 | "complexity": ["error", { "max": 3 }], 23 | "max-lines": ["error", { "max": 100 }], 24 | "max-statements": ["error", { "max": 5 }] 25 | }, 26 | "overrides": [ 27 | { 28 | "files": [ "src/**/*.test.js" ], 29 | "rules": { 30 | "max-lines": ["error", { "max": 200 }], 31 | "max-statements": [ 32 | "error", { "max": 10 }, 33 | { "ignoreTopLevelFunctions": true } 34 | ] 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/git,node,macos,linux,windows,sublimetext,visualstudiocode 3 | 4 | ### Git ### 5 | *.orig 6 | 7 | ### Linux ### 8 | *~ 9 | 10 | # temporary files which can be created if a process still has a handle open of a deleted file 11 | .fuse_hidden* 12 | 13 | # KDE directory preferences 14 | .directory 15 | 16 | # Linux trash folder which might appear on any partition or disk 17 | .Trash-* 18 | 19 | # .nfs files are created when an open file is removed but is still being accessed 20 | .nfs* 21 | 22 | ### macOS ### 23 | *.DS_Store 24 | .AppleDouble 25 | .LSOverride 26 | 27 | # Icon must end with two \r 28 | Icon 29 | 30 | # Thumbnails 31 | ._* 32 | 33 | # Files that might appear in the root of a volume 34 | .DocumentRevisions-V100 35 | .fseventsd 36 | .Spotlight-V100 37 | .TemporaryItems 38 | .Trashes 39 | .VolumeIcon.icns 40 | .com.apple.timemachine.donotpresent 41 | 42 | # Directories potentially created on remote AFP share 43 | .AppleDB 44 | .AppleDesktop 45 | Network Trash Folder 46 | Temporary Items 47 | .apdisk 48 | 49 | ### Node ### 50 | # Logs 51 | logs 52 | *.log 53 | npm-debug.log* 54 | yarn-debug.log* 55 | yarn-error.log* 56 | 57 | # Runtime data 58 | pids 59 | *.pid 60 | *.seed 61 | *.pid.lock 62 | 63 | # Directory for instrumented libs generated by jscoverage/JSCover 64 | lib-cov 65 | 66 | # Coverage directory used by tools like istanbul 67 | coverage 68 | 69 | # nyc test coverage 70 | .nyc_output 71 | 72 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 73 | .grunt 74 | 75 | # Bower dependency directory (https://bower.io/) 76 | bower_components 77 | 78 | # node-waf configuration 79 | .lock-wscript 80 | 81 | # Compiled binary addons (http://nodejs.org/api/addons.html) 82 | build/Release 83 | 84 | # Dependency directories 85 | node_modules/ 86 | jspm_packages/ 87 | 88 | # Typescript v1 declaration files 89 | typings/ 90 | 91 | # Optional npm cache directory 92 | .npm 93 | 94 | # Optional eslint cache 95 | .eslintcache 96 | 97 | # Optional REPL history 98 | .node_repl_history 99 | 100 | # Output of 'npm pack' 101 | *.tgz 102 | 103 | # Yarn Integrity file 104 | .yarn-integrity 105 | 106 | # dotenv environment variables file 107 | .env 108 | 109 | 110 | ### SublimeText ### 111 | # cache files for sublime text 112 | *.tmlanguage.cache 113 | *.tmPreferences.cache 114 | *.stTheme.cache 115 | 116 | # workspace files are user-specific 117 | *.sublime-workspace 118 | 119 | # project files should be checked into the repository, unless a significant 120 | # proportion of contributors will probably not be using SublimeText 121 | # *.sublime-project 122 | 123 | # sftp configuration file 124 | sftp-config.json 125 | 126 | # Package control specific files 127 | Package Control.last-run 128 | Package Control.ca-list 129 | Package Control.ca-bundle 130 | Package Control.system-ca-bundle 131 | Package Control.cache/ 132 | Package Control.ca-certs/ 133 | Package Control.merged-ca-bundle 134 | Package Control.user-ca-bundle 135 | oscrypto-ca-bundle.crt 136 | bh_unicode_properties.cache 137 | 138 | # Sublime-github package stores a github token in this file 139 | # https://packagecontrol.io/packages/sublime-github 140 | GitHub.sublime-settings 141 | 142 | ### VisualStudioCode ### 143 | .vscode/* 144 | !.vscode/settings.json 145 | !.vscode/tasks.json 146 | !.vscode/launch.json 147 | !.vscode/extensions.json 148 | .history 149 | 150 | ### Windows ### 151 | # Windows thumbnail cache files 152 | Thumbs.db 153 | ehthumbs.db 154 | ehthumbs_vista.db 155 | 156 | # Folder config file 157 | Desktop.ini 158 | 159 | # Recycle Bin used on file shares 160 | $RECYCLE.BIN/ 161 | 162 | # Windows Installer files 163 | *.cab 164 | *.msi 165 | *.msm 166 | *.msp 167 | 168 | # Windows shortcuts 169 | *.lnk 170 | 171 | 172 | # End of https://www.gitignore.io/api/git,node,macos,linux,windows,sublimetext,visualstudiocode 173 | 174 | # Built files 175 | dist/ 176 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Rafael Camargo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glorious Demo 2 | 3 | > The easiest way to demonstrate your code in action. 4 | 5 | [![CircleCI](https://circleci.com/gh/glorious-codes/glorious-demo.svg?style=svg)](https://circleci.com/gh/glorious-codes/glorious-demo) 6 | [![Coverage Status](https://coveralls.io/repos/github/glorious-codes/glorious-demo/badge.svg?branch=master)](https://coveralls.io/github/glorious-codes/glorious-demo?branch=master) 7 | 8 |

9 | 10 |

11 | 12 | ## Installation 13 | 14 | ``` 15 | npm install @glorious/demo --save 16 | ``` 17 | 18 | ## Basic Usage 19 | 20 | ``` html 21 | 22 | 23 | ``` 24 | 25 | *Note: If you're not into package management, load it from a third-party [CDN provider](https://github.com/rafaelcamargo/glorious-demo/wiki/CDN-Providers).* 26 | 27 | ``` javascript 28 | // Constructor receives a selector that indicates 29 | // where to inject the demonstration in your page. 30 | const demo = new GDemo('#container'); 31 | 32 | const code = ` 33 | function greet(){ 34 | console.log("Hello World!"); 35 | } 36 | 37 | greet(); 38 | ` 39 | 40 | demo 41 | .openApp('editor', {minHeight: '350px', windowTitle: 'demo.js'}) 42 | .write(code, {onCompleteDelay: 1500}) 43 | .openApp('terminal', {minHeight: '350px', promptString: '$'}) 44 | .command('node ./demo', {onCompleteDelay: 500}) 45 | .respond('Hello World!') 46 | .command('') 47 | .end(); 48 | ``` 49 | 50 | *NOTE: Check [here](https://github.com/rafaelcamargo/glorious-demo/wiki/Syntax-highlight) to know how to use Prism to get your code highlighted.* 51 | 52 | ### API 53 | 54 | #### `openApp` 55 | Opens or maximizes an open application. 56 | ``` javascript 57 | /* 58 | ** @applicationType: String [required] 59 | ** @options: Object [optional] 60 | */ 61 | 62 | // Possible values are 'editor' or 'terminal' 63 | const applicationType = 'terminal'; 64 | 65 | const openAppOptions = { 66 | minHeight: '350px', 67 | windowTitle: 'bash', 68 | id: 'someId', // Identifies an application, in case of multiple instances 69 | inanimate: true, // Turns off application's window animation 70 | promptString: '~/my-project $', // For 'terminal' applications only 71 | initialContent: 'Some text', // For 'editor' applications only 72 | onCompleteDelay: 1000 // Delay before executing the next method 73 | } 74 | 75 | demo.openApp(applicationType, openAppOptions).end(); 76 | ``` 77 | 78 | #### `write` 79 | Writes some code in the open Editor application. 80 | ``` javascript 81 | /* 82 | ** @codeSample: String [required] 83 | ** @options: Object [optional] 84 | */ 85 | 86 | // Tabs and line breaks will be preserved 87 | const codeSample = ` 88 | function sum(a, b) { 89 | return a + b; 90 | } 91 | 92 | sum(); 93 | `; 94 | 95 | const writeOptions = { 96 | id: 'someId', // Identifies an application, in case of multiple instances 97 | onCompleteDelay: 500 // Delay before executing the next method 98 | } 99 | 100 | demo.openApp('editor').write(codeSample, writeOptions).end(); 101 | ``` 102 | 103 | #### `command` 104 | Writes some command in the open Terminal application. 105 | ``` javascript 106 | /* 107 | ** @command: String [required] 108 | ** @options: Object [optional] 109 | */ 110 | 111 | const command = 'npm install @glorious/demo --save'; 112 | 113 | // Redefines prompt string for this and following commands 114 | const promptString = '$' 115 | 116 | // Can optionally be an HTML string: 117 | const promptString = '$' 118 | 119 | const commandOptions = { 120 | id: 'someId', // Identifies an application, in case of multiple instances 121 | promptString, // Sets a custom string. Default: ~/demo $ 122 | onCompleteDelay: 500 // Delay before executing the next method 123 | } 124 | 125 | demo.openApp('terminal').command(command, commandOptions).end(); 126 | ``` 127 | 128 | #### `respond` 129 | Shows some response on the open Terminal application. 130 | ``` javascript 131 | /* 132 | ** @response: String [required] 133 | ** @options: Object [optional] 134 | */ 135 | 136 | // Line breaks will be preserved 137 | const response = ` 138 | + @glorious/demo successfully installed! 139 | + v0.1.0 140 | `; 141 | 142 | // Can optionally be an HTML string: 143 | const response = ` 144 |
+ @glorious/demo successfully installed!
145 |
+ v0.6.0
146 | `; 147 | 148 | const respondOptions = { 149 | id: 'someId', // Identifies an application, in case of multiple instances 150 | onCompleteDelay: 500 // Delay before executing the next method 151 | } 152 | 153 | demo.openApp('terminal').respond(response, respondOptions).end(); 154 | ``` 155 | 156 | #### `end` 157 | Indicates the end of the demonstration. The method returns a promise in case you want to perform some action at the end of the demonstration. 158 | 159 | ``` javascript 160 | demo.openApp('terminal') 161 | .command('node demo') 162 | .respond('Hello World!') 163 | .end() 164 | .then(() => { 165 | // Custom code to be performed at the end of the demostration goes here. 166 | }); 167 | ``` 168 | 169 | **IMPORTANT:** Do not forget to invoke it at the end of your demo. Otherwise, the demo won't be played. 170 | 171 | ## Contributing 172 | 173 | 1. Install [Node](https://nodejs.org/en/). Download the "Recommend for Most Users" version. 174 | 175 | 2. Clone the repo: 176 | ``` bash 177 | git clone git@github.com:glorious-codes/glorious-demo.git 178 | ``` 179 | 180 | 3. Go to the project directory: 181 | ``` bash 182 | cd glorious-demo 183 | ``` 184 | 185 | 4. Install the project dependencies: 186 | ``` bash 187 | npm install 188 | ``` 189 | 190 | 5. Build the project: 191 | ``` bash 192 | npm run build 193 | ``` 194 | 195 | ## Tests 196 | 197 | Ensure that all code that you have added is covered with unit tests: 198 | ``` bash 199 | npm run test -- --coverage 200 | ``` 201 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const project = require('./project.json'); 2 | 3 | module.exports = { 4 | "clearMocks": true, 5 | "collectCoverageFrom": [project.scripts.source.files], 6 | "coverageReporters": ["html"], 7 | "coverageThreshold": { 8 | "global": { 9 | "statements": 100, 10 | "branches": 100, 11 | "functions": 100, 12 | "lines": 100 13 | } 14 | }, 15 | "moduleNameMapper": { 16 | '@styles\/(.*)$': `/${project.styles.source.root}$1`, 17 | '@mocks\/(.*)$': `/${project.mocks.source.root}$1` 18 | }, 19 | "transform": { 20 | "^.+\\.(css|styl)$": "/src/mocks/styles-mock.js", 21 | "^.+\\.js$": "babel-jest", 22 | "^.+\\.html$": "html-loader-jest" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@glorious/demo", 3 | "version": "0.12.0", 4 | "description": "The easiest way to demonstrate your code in action", 5 | "main": "dist/gdemo.min.js", 6 | "files": [ 7 | "dist/**/*.*" 8 | ], 9 | "scripts": { 10 | "format": "node ./node_modules/eslint/bin/eslint.js ./src/**/*.js", 11 | "build": "node ./node_modules/webpack/bin/webpack.js", 12 | "test": "node ./node_modules/jest/bin/jest.js", 13 | "prepublishOnly": "rm -rf ./dist && NODE_ENV=production npm run build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/rafaelcamargo/glorious-demo.git" 18 | }, 19 | "keywords": [ 20 | "demo", 21 | "demonstration", 22 | "code", 23 | "snippet", 24 | "animation", 25 | "mac", 26 | "editor", 27 | "terminal" 28 | ], 29 | "author": "Rafael Camargo ", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/rafaelcamargo/glorious-demo/issues" 33 | }, 34 | "homepage": "https://github.com/rafaelcamargo/glorious-demo#readme", 35 | "devDependencies": { 36 | "@babel/core": "^7.6.4", 37 | "@babel/preset-env": "^7.6.3", 38 | "babel-jest": "^24.9.0", 39 | "babel-loader": "^8.0.6", 40 | "css-loader": "^3.5.2", 41 | "eslint": "^5.6.0", 42 | "html-loader": "^0.5.5", 43 | "html-loader-jest": "^0.2.1", 44 | "jest": "^24.9.0", 45 | "mini-css-extract-plugin": "^0.8.0", 46 | "optimize-css-assets-webpack-plugin": "^5.0.3", 47 | "style-loader": "^0.20.3", 48 | "stylus": "^0.54.5", 49 | "stylus-loader": "^3.0.1", 50 | "terser-webpack-plugin": "^2.3.5", 51 | "webpack": "^4.42.1", 52 | "webpack-cli": "^3.3.11", 53 | "webpack-merge": "^4.2.2" 54 | }, 55 | "dependencies": { 56 | "@glorious/fyzer": "^0.1.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /project.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "source": { 4 | "root": "src/scripts/", 5 | "files": "src/scripts/**/*.js", 6 | "entry": "src/scripts/index.js" 7 | }, 8 | "dist": { 9 | "root": "dist", 10 | "filename": { 11 | "prod": "gdemo.min.js", 12 | "dev": "gdemo.js" 13 | } 14 | } 15 | }, 16 | "styles": { 17 | "source": { 18 | "root": "src/styles/" 19 | }, 20 | "dist": { 21 | "filename": { 22 | "prod": "gdemo.min.css", 23 | "dev": "gdemo.css" 24 | } 25 | } 26 | }, 27 | "mocks": { 28 | "source": { 29 | "root": "src/mocks/" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/mocks/cursor-mock.js: -------------------------------------------------------------------------------- 1 | export const cursorInstanceMock = { 2 | element: document.createElement('span'), 3 | write: jest.fn() 4 | }; 5 | 6 | export const CursorMock = jest.fn(() => { 7 | return cursorInstanceMock; 8 | }); 9 | -------------------------------------------------------------------------------- /src/mocks/desktop-mock.js: -------------------------------------------------------------------------------- 1 | export const desktopInstanceMock = { 2 | openApplication: jest.fn(), 3 | minimizeAllApplications: jest.fn(onComplete => { 4 | onComplete(); 5 | }), 6 | maximizeApplication: jest.fn((application, onComplete) => { 7 | onComplete(); 8 | }) 9 | }; 10 | 11 | export const TerminalApplicationMock = jest.fn(() => { 12 | return desktopInstanceMock; 13 | }); 14 | -------------------------------------------------------------------------------- /src/mocks/editor-application-mock.js: -------------------------------------------------------------------------------- 1 | export const editorApplicationInstanceMock = { 2 | minimize: jest.fn(), 3 | maximize: jest.fn(), 4 | element: '
Something
', 5 | type: 'editor' 6 | }; 7 | 8 | export const EditorApplicationMock = jest.fn((containerEl, { inanimate, id } = {}) => { 9 | return { 10 | ...editorApplicationInstanceMock, 11 | inanimate, 12 | id 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /src/mocks/editor-line-mock.js: -------------------------------------------------------------------------------- 1 | export const editorLineInstanceMock = { 2 | setActive: jest.fn(), 3 | setInactive: jest.fn(), 4 | write: jest.fn((textLine, onComplete) => { 5 | onComplete(); 6 | }), 7 | element: document.createElement('div') 8 | }; 9 | 10 | export const EditorLineMock = jest.fn(() => { 11 | return editorLineInstanceMock; 12 | }); 13 | -------------------------------------------------------------------------------- /src/mocks/player-mock.js: -------------------------------------------------------------------------------- 1 | export const playerInstanceMock = { 2 | play: jest.fn(() => { 3 | return { 4 | then: resolve => resolve() 5 | }; 6 | }) 7 | }; 8 | 9 | export const PlayerMock = jest.fn(() => { 10 | return playerInstanceMock; 11 | }); 12 | -------------------------------------------------------------------------------- /src/mocks/styles-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | process() { 3 | return ''; 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /src/mocks/terminal-application-mock.js: -------------------------------------------------------------------------------- 1 | export const terminalApplicationInstanceMock = { 2 | minimize: jest.fn(), 3 | maximize: jest.fn(), 4 | element: '
Something
', 5 | type: 'terminal' 6 | }; 7 | 8 | export const TerminalApplicationMock = jest.fn((containerEl, { inanimate, id } = {}) => { 9 | return { 10 | ...terminalApplicationInstanceMock, 11 | inanimate, 12 | id 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /src/mocks/terminal-command-line-mock.js: -------------------------------------------------------------------------------- 1 | export const terminalCommandLineInstanceMock = { 2 | command: jest.fn((textLine, onComplete) => { 3 | onComplete(); 4 | }), 5 | setActive: jest.fn(), 6 | setInactive: jest.fn(), 7 | element: '
Something
' 8 | }; 9 | 10 | export const TerminalCommandLineMock = jest.fn(() => { 11 | return terminalCommandLineInstanceMock; 12 | }); 13 | -------------------------------------------------------------------------------- /src/mocks/terminal-response-line-mock.js: -------------------------------------------------------------------------------- 1 | export const terminalResponseLineInstanceMock = { 2 | setText: jest.fn(), 3 | element: '
Something
' 4 | }; 5 | 6 | export const TerminalResponseLineMock = jest.fn(() => { 7 | return terminalResponseLineInstanceMock; 8 | }); 9 | -------------------------------------------------------------------------------- /src/scripts/components/application/application.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | -------------------------------------------------------------------------------- /src/scripts/components/application/application.js: -------------------------------------------------------------------------------- 1 | import '@styles/application.styl'; 2 | import { DEFAULT_APPLICATION_ID } from '../../constants/application'; 3 | import domService from '../../services/dom/dom'; 4 | import textService from '../../services/text/text'; 5 | import template from './application.html'; 6 | 7 | export class Application { 8 | constructor(applicationType, { id = DEFAULT_APPLICATION_ID, ...options } = {}){ 9 | this.type = applicationType; 10 | this.id = id; 11 | this.options = options; 12 | this.element = buildElement(applicationType); 13 | this.setOptions(this.options); 14 | } 15 | setOptions(options){ 16 | Object.entries(options).forEach(([optionName, optionValue]) => { 17 | const handle = this.getOptionHandler(optionName) 18 | handle && handle(optionValue) 19 | }); 20 | } 21 | getOptionHandler(optionName){ 22 | return { 23 | 'minHeight': optionValue => this.setMinHeight(optionValue), 24 | 'windowTitle': optionValue => this.setWindowTitle(optionValue), 25 | 'inanimate': optionValue => this.configAnimation(optionValue), 26 | }[optionName]; 27 | } 28 | setMinHeight(height){ 29 | const applicationTopbarHeight = 26; 30 | const contentContainer = getContentContainerElement(this.element); 31 | const contentContainerMinHeight = parseInt(height) - applicationTopbarHeight; 32 | contentContainer.style.minHeight = `${contentContainerMinHeight}px`; 33 | } 34 | setWindowTitle(title){ 35 | const titleContainerElement = getWindowTitleContainerElement(this.element); 36 | titleContainerElement.innerText = title; 37 | this.windowTitle = title; 38 | } 39 | configAnimation(inanimate){ 40 | this.setInanimate(inanimate); 41 | inanimate && getBaseApplicationElement(this.element).classList.add('application-inanimate'); 42 | } 43 | addContent(content){ 44 | const container = getContentContainerElement(this.element); 45 | container.appendChild(content); 46 | } 47 | setInanimate(inanimate){ 48 | this.inanimate = inanimate; 49 | } 50 | minimize(){ 51 | this.setMaximized(false); 52 | handleResizingCssClass(this.element, 'remove', 'application-maximized'); 53 | handleResizingCssClass(this.element, 'add', 'application-minimized'); 54 | } 55 | maximize(){ 56 | this.setMaximized(true); 57 | handleResizingCssClass(this.element, 'remove', 'application-minimized'); 58 | handleResizingCssClass(this.element, 'add', 'application-maximized'); 59 | } 60 | setMaximized(isMaximized){ 61 | this.isMaximized = isMaximized; 62 | } 63 | } 64 | 65 | function buildElement(applicationType){ 66 | let element = buildWrapper(applicationType); 67 | element.appendChild(domService.parseHtml(template)); 68 | return element; 69 | } 70 | 71 | function buildWrapper(applicationType){ 72 | const wrapper = document.createElement('div'); 73 | const cssClass = `${textService.toKebabCase(applicationType)}-application`; 74 | wrapper.setAttribute('class', cssClass); 75 | return wrapper; 76 | } 77 | 78 | function getContentContainerElement(applicationElement){ 79 | return applicationElement.querySelector('[data-content-container]'); 80 | } 81 | 82 | function getWindowTitleContainerElement(applicationElement){ 83 | return applicationElement.querySelector('[data-title-container]'); 84 | } 85 | 86 | function handleResizingCssClass(element, classListMethod, cssClass){ 87 | getBaseApplicationElement(element).classList[classListMethod](cssClass); 88 | } 89 | 90 | function getBaseApplicationElement(element){ 91 | return element.querySelector('[data-application]'); 92 | } 93 | -------------------------------------------------------------------------------- /src/scripts/components/application/application.test.js: -------------------------------------------------------------------------------- 1 | import { Application } from './application'; 2 | 3 | jest.useFakeTimers(); 4 | 5 | describe('Application Component', () => { 6 | 7 | it('should have a default id', () => { 8 | const application = new Application('editor'); 9 | expect(application.id).toEqual('_default'); 10 | }); 11 | 12 | it('should optionally have a custom id', () => { 13 | const id = 'editor3'; 14 | const application = new Application('editor', { id }); 15 | expect(application.id).toEqual(id); 16 | }); 17 | 18 | it('should instantiate application from application type', () => { 19 | const application = new Application('editor'); 20 | expect(application.element.classList.length).toEqual(1); 21 | expect(application.element.classList[0]).toEqual('editor-application'); 22 | }); 23 | 24 | it('should have a topbar', () => { 25 | const application = new Application('editor'); 26 | const topbar = application.element.querySelector('application-topbar'); 27 | expect(topbar).toBeDefined(); 28 | }); 29 | 30 | it('should have a title container', () => { 31 | const application = new Application('editor'); 32 | const titleContainer = application.element.querySelector('[data-title-container]'); 33 | expect(titleContainer).toBeDefined(); 34 | }); 35 | 36 | it('should have a content container', () => { 37 | const application = new Application('editor'); 38 | const contentContainer = application.element.querySelector('[data-content-container]'); 39 | expect(contentContainer).toBeDefined(); 40 | }); 41 | 42 | it('should minimum height option subtract application top bar height', () => { 43 | const minHeight = '300px'; 44 | const application = new Application('editor', { minHeight }); 45 | const contentContainer = application.element.querySelector('[data-content-container]'); 46 | expect(contentContainer.style.minHeight).toEqual('274px'); 47 | }); 48 | 49 | it('should allow window title option', () => { 50 | const windowTitle = 'Atom'; 51 | const application = new Application('editor', { windowTitle }); 52 | const titleContainer = application.element.querySelector('[data-title-container]'); 53 | expect(titleContainer.innerText).toEqual(windowTitle); 54 | }); 55 | 56 | it('should add content', () => { 57 | const content = document.createElement('h1'); 58 | const application = new Application('editor'); 59 | content.innerText = 'Some content.'; 60 | application.addContent(content); 61 | expect(application.element.querySelector('h1')).toEqual(content); 62 | }); 63 | 64 | it('should maximize', () => { 65 | const application = new Application('editor'); 66 | application.maximize(); 67 | expect(application.isMaximized).toEqual(true); 68 | }); 69 | 70 | it('should set maximized look on maximize', () => { 71 | const application = new Application('editor'); 72 | const applicationElement = application.element.querySelector('[data-application]'); 73 | application.maximize(); 74 | expect(applicationElement.classList.contains('application-minimized')).toEqual(false); 75 | expect(applicationElement.classList.contains('application-maximized')).toEqual(true); 76 | }); 77 | 78 | it('should minimize', () => { 79 | const application = new Application('editor'); 80 | application.minimize(); 81 | expect(application.isMaximized).toEqual(false); 82 | }); 83 | 84 | it('should set minimized look on minimize', () => { 85 | const application = new Application('editor'); 86 | const applicationElement = application.element.querySelector('[data-application]'); 87 | application.minimize(); 88 | expect(applicationElement.classList.contains('application-minimized')).toEqual(true); 89 | expect(applicationElement.classList.contains('application-maximized')).toEqual(false); 90 | }); 91 | 92 | it('should optionally set application as inanimate', () => { 93 | const application = new Application('editor', { inanimate: true }); 94 | const applicationElement = application.element.querySelector('[data-application]'); 95 | expect(applicationElement.classList.contains('application-inanimate')).toEqual(true); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/scripts/components/cursor/cursor.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/scripts/components/cursor/cursor.js: -------------------------------------------------------------------------------- 1 | import '@styles/cursor.styl'; 2 | import domService from '../../services/dom/dom'; 3 | import typeService from '../../services/type/type'; 4 | import template from './cursor.html'; 5 | 6 | const ACTIVE_CSS_CLASS = 'cursor-active'; 7 | 8 | export class Cursor { 9 | constructor(){ 10 | this.element = domService.parseHtml(template); 11 | } 12 | write(text, onComplete){ 13 | typeService.type(this.element, text, onComplete); 14 | } 15 | setActive(){ 16 | handleCssClass(this.element, 'add', ACTIVE_CSS_CLASS); 17 | } 18 | setInactive(){ 19 | handleCssClass(this.element, 'remove', ACTIVE_CSS_CLASS); 20 | } 21 | } 22 | 23 | function handleCssClass(cursorElement, action, cssClass){ 24 | cursorElement.classList[action](cssClass); 25 | } 26 | -------------------------------------------------------------------------------- /src/scripts/components/cursor/cursor.test.js: -------------------------------------------------------------------------------- 1 | import typeService from '../../services/type/type'; 2 | import { Cursor } from './cursor'; 3 | 4 | describe('Cursor', () => { 5 | function mockContent(){ 6 | return document.createElement('div'); 7 | } 8 | 9 | it('should write some text', () => { 10 | typeService.type = jest.fn(); 11 | const onComplete = jest.fn(); 12 | const text = 'some text'; 13 | const cursor = new Cursor(mockContent()); 14 | cursor.write(text, onComplete); 15 | expect(typeService.type).toHaveBeenCalledWith(cursor.element, text, onComplete); 16 | }); 17 | 18 | it('should set cursor as active', () => { 19 | const cursor = new Cursor(mockContent()); 20 | cursor.setActive(); 21 | expect(cursor.element.classList.contains('cursor-active')).toEqual(true); 22 | }); 23 | 24 | it('should set cursor as inactive', () => { 25 | const cursor = new Cursor(mockContent()); 26 | cursor.element.classList.add('cursor-active'); 27 | cursor.setInactive(); 28 | expect(cursor.element.classList.contains('cursor-active')).toEqual(false); 29 | }); 30 | 31 | }); 32 | -------------------------------------------------------------------------------- /src/scripts/components/desktop/desktop.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /src/scripts/components/desktop/desktop.js: -------------------------------------------------------------------------------- 1 | import '@styles/desktop.styl'; 2 | import { DEFAULT_APPLICATION_ID } from '../../constants/application'; 3 | import { EditorApplication } from '../editor-application/editor-application'; 4 | import { TerminalApplication } from '../terminal-application/terminal-application'; 5 | import domService from '../../services/dom/dom'; 6 | import template from './desktop.html'; 7 | 8 | export class Desktop { 9 | constructor(container){ 10 | this.element = domService.parseHtml(template); 11 | this.container = container; 12 | this.openApplications = []; 13 | this.container.appendChild(this.element); 14 | } 15 | openApplication(appType, appOptions){ 16 | const app = getOpenApplication(this.openApplications, appType, appOptions); 17 | return app ? app : buildApplication(this, appType, appOptions); 18 | } 19 | minimizeAllApplications(onComplete){ 20 | this.openApplications.forEach(openApplication => openApplication.minimize()); 21 | if(onComplete) setTimeout(onComplete, getDefaultAnimationDuration()); 22 | } 23 | maximizeApplication(application, onComplete){ 24 | application.maximize(); 25 | if(application.inanimate) return onComplete(); 26 | return setTimeout(onComplete, getDefaultAnimationDuration()); 27 | } 28 | } 29 | 30 | function buildApplication(desktop, appType, options){ 31 | const Application = appType == 'editor' ? EditorApplication: TerminalApplication; 32 | const application = new Application(desktop.element, options); 33 | desktop.openApplications.push(application); 34 | desktop.element.appendChild(application.element); 35 | return application; 36 | } 37 | 38 | function getOpenApplication(openApplications, appType, appOptions = {}){ 39 | const appId = appOptions.id || DEFAULT_APPLICATION_ID; 40 | return openApplications.filter(openApplication => { 41 | return openApplication.type === appType; 42 | }).find(openApplication => { 43 | return openApplication.id === appId; 44 | }); 45 | } 46 | 47 | function getDefaultAnimationDuration(){ 48 | return 750; 49 | } 50 | -------------------------------------------------------------------------------- /src/scripts/components/desktop/desktop.test.js: -------------------------------------------------------------------------------- 1 | import { Desktop } from './desktop'; 2 | import { EditorApplication } from '../editor-application/editor-application'; 3 | import { TerminalApplication } from '../terminal-application/terminal-application'; 4 | import { EditorApplicationMock } from '@mocks/editor-application-mock'; 5 | import { TerminalApplicationMock } from '@mocks/terminal-application-mock'; 6 | 7 | jest.mock('../editor-application/editor-application'); 8 | EditorApplication.mockImplementation(EditorApplicationMock); 9 | 10 | jest.mock('../terminal-application/terminal-application'); 11 | TerminalApplication.mockImplementation(TerminalApplicationMock); 12 | 13 | describe('Desktop Component', () => { 14 | 15 | jest.useFakeTimers(); 16 | 17 | function instantiateDesktop(){ 18 | const container = document.createElement('div'); 19 | return new Desktop(container); 20 | } 21 | 22 | it('should build desktop on instantiate', () => { 23 | const desktop = instantiateDesktop(); 24 | expect(desktop.element.classList[0]).toEqual('desktop'); 25 | }); 26 | 27 | it('should open an application', () => { 28 | const desktop = instantiateDesktop(); 29 | spyOn(desktop.element, 'appendChild'); 30 | const application = desktop.openApplication('editor'); 31 | expect(desktop.openApplications[0]).toEqual(application); 32 | }); 33 | 34 | it('should instantiate an editor application when first opening it', () => { 35 | const desktop = instantiateDesktop(); 36 | spyOn(desktop.element, 'appendChild'); 37 | const options = {some: 'option'}; 38 | desktop.openApplication('editor', options); 39 | expect(EditorApplication).toHaveBeenCalledWith(desktop.element, options); 40 | }); 41 | 42 | it('should instantiate a terminal application when first opening it', () => { 43 | const desktop = instantiateDesktop(); 44 | spyOn(desktop.element, 'appendChild'); 45 | const options = {some: 'option'}; 46 | desktop.openApplication('terminal', options); 47 | expect(TerminalApplication).toHaveBeenCalledWith(desktop.element, options); 48 | }); 49 | 50 | it('should not instantiate an application when that application was already open', () => { 51 | const desktop = instantiateDesktop(); 52 | spyOn(desktop.element, 'appendChild'); 53 | desktop.openApplication('editor'); 54 | desktop.openApplication('terminal'); 55 | desktop.openApplication('editor'); 56 | expect(EditorApplication).not.toHaveBeenCalledTimes(1); 57 | }); 58 | 59 | it('should optionally instantiate an application more than once', () => { 60 | const desktop = instantiateDesktop(); 61 | spyOn(desktop.element, 'appendChild'); 62 | desktop.openApplication('editor', { id: 'editor1' }); 63 | desktop.openApplication('editor', { id: 'editor2' }); 64 | desktop.openApplication('editor', { id: 'editor1' }); 65 | desktop.openApplication('editor', { id: 'editor2' }); 66 | expect(EditorApplication).toHaveBeenCalledTimes(2); 67 | }); 68 | 69 | it('should append the application element to the desktop when opening it', () => { 70 | const desktop = instantiateDesktop(); 71 | spyOn(desktop.element, 'appendChild'); 72 | const options = {some: 'option'}; 73 | const application = desktop.openApplication('terminal', options); 74 | expect(desktop.element.appendChild).toHaveBeenCalledWith(application.element); 75 | }); 76 | 77 | it('should minimize all open applications', () => { 78 | const desktop = instantiateDesktop(); 79 | spyOn(desktop.element, 'appendChild'); 80 | const editor = desktop.openApplication('editor'); 81 | const terminal = desktop.openApplication('terminal'); 82 | desktop.minimizeAllApplications(); 83 | expect(editor.minimize).toHaveBeenCalled(); 84 | expect(terminal.minimize).toHaveBeenCalled(); 85 | }); 86 | 87 | it('should optionally execute complete callback when minimization ends', () => { 88 | const onComplete = jest.fn(); 89 | const desktop = instantiateDesktop(); 90 | desktop.minimizeAllApplications(onComplete); 91 | expect(setTimeout).toHaveBeenCalledWith(onComplete, 750); 92 | }); 93 | 94 | it('should maximize some application', () => { 95 | const desktop = instantiateDesktop(); 96 | spyOn(desktop.element, 'appendChild'); 97 | const application = desktop.openApplication('editor'); 98 | desktop.maximizeApplication(application); 99 | expect(application.maximize).toHaveBeenCalled(); 100 | }); 101 | 102 | it('should execute complete callback when maximization ends', () => { 103 | const onComplete = jest.fn(); 104 | const desktop = instantiateDesktop(); 105 | spyOn(desktop.element, 'appendChild'); 106 | const application = desktop.openApplication('editor'); 107 | desktop.maximizeApplication(application, onComplete); 108 | expect(setTimeout).toHaveBeenCalledWith(onComplete, 750); 109 | }); 110 | 111 | it('should execute complete callback instantly when maximizing inanimate applications', () => { 112 | const onComplete = jest.fn(); 113 | const desktop = instantiateDesktop(); 114 | spyOn(desktop.element, 'appendChild'); 115 | const application = desktop.openApplication('editor', { inanimate: true }); 116 | desktop.maximizeApplication(application, onComplete); 117 | expect(onComplete).toHaveBeenCalled(); 118 | expect(setTimeout).not.toHaveBeenCalled(); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/scripts/components/editor-application/editor-application.js: -------------------------------------------------------------------------------- 1 | import '@styles/editor-application.styl'; 2 | import { Application } from '../application/application'; 3 | import { EditorLine } from '../editor-line/editor-line'; 4 | import textService from '../../services/text/text'; 5 | 6 | export class EditorApplication extends Application { 7 | constructor(container, options = {}){ 8 | super('editor', options); 9 | this.container = container; 10 | this.container.appendChild(this.element); 11 | this.setupInitialContent(options.initialContent); 12 | this.setWindowTitle(buildWindowTitle(options)); 13 | } 14 | setupInitialContent(initialContent = ''){ 15 | const linesToAppend = parseContent(initialContent); 16 | this.setLines([]); 17 | this.appendLines(linesToAppend); 18 | } 19 | setLines(lines){ 20 | this.lines = []; 21 | } 22 | appendLines(textLines){ 23 | textLines.forEach(textLine => { 24 | const line = buildEditorLine(this.lines, textLine); 25 | appendContentToApplication(this, line); 26 | }); 27 | } 28 | write({ codeSample }, onComplete){ 29 | const textLines = parseContent(codeSample); 30 | writeMultipleLines(this, textLines, onComplete); 31 | } 32 | } 33 | 34 | function parseContent(content){ 35 | return textService.removeBlankFirstLine(content); 36 | } 37 | 38 | function buildWindowTitle(options){ 39 | return options.windowTitle || '~/demo/demo.js'; 40 | } 41 | 42 | function writeMultipleLines(application, textLines, onComplete){ 43 | const textLine = textLines.shift(); 44 | if(textLine !== undefined){ 45 | inactivateLastLineWritten(application.lines); 46 | writeSingleLine(application, textLine, () => { 47 | writeMultipleLines(application, textLines, onComplete); 48 | }); 49 | } else { 50 | onComplete(); 51 | } 52 | } 53 | 54 | function inactivateLastLineWritten(lines){ 55 | if(lines.length) lines[lines.length-1].setInactive(); 56 | } 57 | 58 | function writeSingleLine(application, text, onComplete){ 59 | const line = buildEditorLine(application.lines); 60 | appendContentToApplication(application, line); 61 | line.setActive(); 62 | line.write(text, onComplete); 63 | } 64 | 65 | function buildEditorLine(applicationLines, text){ 66 | return new EditorLine(applicationLines.length + 1, text); 67 | } 68 | 69 | function appendContentToApplication(application, line){ 70 | application.addContent(line.element); 71 | application.lines.push(line); 72 | } 73 | -------------------------------------------------------------------------------- /src/scripts/components/editor-application/editor-application.test.js: -------------------------------------------------------------------------------- 1 | import textService from '../../services/text/text'; 2 | import { EditorLine } from '../editor-line/editor-line'; 3 | import { EditorApplication } from './editor-application'; 4 | import { EditorLineMock, editorLineInstanceMock } from '@mocks/editor-line-mock'; 5 | 6 | jest.mock('../editor-line/editor-line'); 7 | EditorLine.mockImplementation(EditorLineMock); 8 | 9 | describe('Editor Application Component', () => { 10 | 11 | function stubRemoveBlankFirstLine(value){ 12 | spyOn(textService, 'removeBlankFirstLine').and.returnValue(value); 13 | } 14 | 15 | function instantiateEditorApplication(options){ 16 | const container = document.createElement('div'); 17 | return new EditorApplication(container, options); 18 | } 19 | 20 | it('should build editor application wrapper on instantiate', () => { 21 | const application = instantiateEditorApplication(); 22 | expect(application.element.classList[0]).toEqual('editor-application'); 23 | }); 24 | 25 | it('should append lines on instantiate if initial content has been given', () => { 26 | const initialContent = `Some text. 27 | Some more text.`; 28 | const application = instantiateEditorApplication({ initialContent }); 29 | expect(EditorLineMock).toHaveBeenCalledWith(1, 'Some text.'); 30 | expect(EditorLineMock).toHaveBeenCalledWith(2, 'Some more text.'); 31 | expect(application.lines.length).toEqual(2); 32 | }); 33 | 34 | it('should config window title with default window title if no window title option is given', () => { 35 | const application = instantiateEditorApplication(); 36 | expect(application.windowTitle).toEqual('~/demo/demo.js'); 37 | }); 38 | 39 | it('should config window title with the window title given as option', () => { 40 | const windowTitle = 'Atom'; 41 | const application = instantiateEditorApplication({ windowTitle }); 42 | expect(application.windowTitle).toEqual(windowTitle); 43 | }); 44 | 45 | it('should add written line to the application content', () => { 46 | const application = instantiateEditorApplication(); 47 | const codeSample = 'Line 1'; 48 | const onComplete = jest.fn(); 49 | application.addContent = jest.fn(); 50 | stubRemoveBlankFirstLine(codeSample.split('\n')); 51 | application.write({ codeSample }, onComplete); 52 | expect(application.addContent).toHaveBeenCalledWith(editorLineInstanceMock.element); 53 | }); 54 | 55 | it('should instantiate an editor application line when writing some code', () => { 56 | const application = instantiateEditorApplication(); 57 | const codeSample = 'Line 1'; 58 | const onComplete = jest.fn(); 59 | application.addContent = jest.fn(); 60 | stubRemoveBlankFirstLine(codeSample.split('\n')); 61 | application.write({ codeSample }, onComplete); 62 | expect(EditorLineMock).toHaveBeenCalledWith(1, undefined); 63 | }); 64 | 65 | it('should set line as active on write', () => { 66 | const application = instantiateEditorApplication(); 67 | const codeSample = 'Line 1'; 68 | const onComplete = jest.fn(); 69 | application.addContent = jest.fn(); 70 | stubRemoveBlankFirstLine(codeSample.split('\n')); 71 | application.write({ codeSample }, onComplete); 72 | expect(editorLineInstanceMock.setActive.mock.calls.length).toEqual(1); 73 | }); 74 | 75 | it('should write a single-line code', () => { 76 | const application = instantiateEditorApplication(); 77 | const codeSample = 'Line 1'; 78 | const onComplete = jest.fn(); 79 | application.addContent = jest.fn(); 80 | stubRemoveBlankFirstLine(codeSample.split('\n')); 81 | application.write({ codeSample }, onComplete); 82 | expect(editorLineInstanceMock.write.mock.calls[0][0]).toEqual(codeSample); 83 | expect(typeof editorLineInstanceMock.write.mock.calls[0][1]).toEqual('function'); 84 | }); 85 | 86 | it('should write a multi-line code', () => { 87 | const application = instantiateEditorApplication(); 88 | const codeSample = 'Line 1\nLine 2\nLine 3'; 89 | const onComplete = jest.fn(); 90 | application.addContent = jest.fn(); 91 | stubRemoveBlankFirstLine(codeSample.split('\n')); 92 | application.write({ codeSample }, onComplete); 93 | expect(editorLineInstanceMock.write.mock.calls[0][0]).toEqual('Line 1'); 94 | expect(editorLineInstanceMock.write.mock.calls[1][0]).toEqual('Line 2'); 95 | expect(editorLineInstanceMock.write.mock.calls[2][0]).toEqual('Line 3'); 96 | expect(editorLineInstanceMock.write.mock.calls.length).toEqual(3); 97 | }); 98 | 99 | it('should set last line written as inactive when writing a new line', () => { 100 | const application = instantiateEditorApplication(); 101 | const codeSample = 'Line 1\nLine 2'; 102 | const onComplete = jest.fn(); 103 | application.addContent = jest.fn(); 104 | stubRemoveBlankFirstLine(codeSample.split('\n')); 105 | application.write({ codeSample }, onComplete); 106 | expect(editorLineInstanceMock.setInactive.mock.calls.length).toEqual(1); 107 | }); 108 | 109 | it('should execute on complete callback after finish writing some code', () => { 110 | const application = instantiateEditorApplication(); 111 | const codeSample = 'Line 1'; 112 | const onComplete = jest.fn(); 113 | application.addContent = jest.fn(); 114 | stubRemoveBlankFirstLine(codeSample.split('\n')); 115 | application.write({ codeSample }, onComplete); 116 | expect(onComplete).toHaveBeenCalled(); 117 | }); 118 | 119 | }); 120 | -------------------------------------------------------------------------------- /src/scripts/components/editor-line/editor-line.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | 
5 | -------------------------------------------------------------------------------- /src/scripts/components/editor-line/editor-line.js: -------------------------------------------------------------------------------- 1 | import '@styles/editor-line.styl'; 2 | import { Cursor } from '../cursor/cursor'; 3 | import domService from '../../services/dom/dom'; 4 | import template from './editor-line.html'; 5 | 6 | export class EditorLine { 7 | constructor(lineNumber, writtenText){ 8 | this.cursor = new Cursor(); 9 | this.element = domService.parseHtml(template); 10 | setNumber(this.element, lineNumber); 11 | setupWrittenText(this.element, writtenText); 12 | if(!writtenText) 13 | getTextElement(this.element).appendChild(this.cursor.element); 14 | } 15 | write(text, onComplete){ 16 | this.cursor.write(text, onComplete); 17 | } 18 | setActive(){ 19 | this.cursor.setActive(); 20 | } 21 | setInactive(){ 22 | this.cursor.setInactive(); 23 | } 24 | } 25 | 26 | function setNumber(lineElement, number){ 27 | getNumberElement(lineElement).innerText = number; 28 | } 29 | 30 | function getNumberElement(lineElement){ 31 | return getInnerElement(lineElement, '[data-editor-line-number]'); 32 | } 33 | 34 | function getTextElement(lineElement){ 35 | return getInnerElement(lineElement, '[data-editor-line-text]'); 36 | } 37 | 38 | function getInnerElement(lineElement, selector){ 39 | return lineElement.querySelector(selector); 40 | } 41 | 42 | function setupWrittenText(lineElement, text = ''){ 43 | getTextElement(lineElement).innerHTML = text; 44 | } 45 | -------------------------------------------------------------------------------- /src/scripts/components/editor-line/editor-line.test.js: -------------------------------------------------------------------------------- 1 | import typeService from '../../services/type/type'; 2 | import { Cursor } from '../cursor/cursor'; 3 | import { EditorLine } from './editor-line'; 4 | import { CursorMock } from '@mocks/cursor-mock'; 5 | 6 | jest.mock('../cursor/cursor'); 7 | Cursor.mockImplementation(CursorMock); 8 | 9 | describe('Editor Line Component', () => { 10 | 11 | beforeEach(() => { 12 | spyOn(typeService, 'type'); 13 | }); 14 | 15 | it('should have appropriate css class', () => { 16 | const line = new EditorLine(); 17 | expect(line.element.classList[0]).toEqual('editor-line'); 18 | }); 19 | 20 | it('should set line number on instantiate', () => { 21 | const line = new EditorLine(1); 22 | const numberElement = line.element.querySelector('[data-editor-line-number]'); 23 | expect(numberElement.innerText).toEqual(1); 24 | }); 25 | 26 | it('should append a cursor element in editor line text element on instantiate', () => { 27 | const line = new EditorLine(1); 28 | const cursorElement = line.element.querySelectorAll('[data-editor-line-text] > span'); 29 | expect(cursorElement.length).toEqual(1); 30 | }); 31 | 32 | it('should not append a cursor element if line contains some written text on instantiate', () => { 33 | const line = new EditorLine(1, 'Some text'); 34 | const cursorElement = line.element.querySelectorAll('[data-editor-line-text] > span'); 35 | expect(cursorElement.length).toEqual(0); 36 | }); 37 | 38 | it('should write some text using cursor', () => { 39 | const line = new EditorLine(1); 40 | const text = 'const number = 1;'; 41 | const onComplete = jest.fn(); 42 | line.write(text, onComplete); 43 | expect(line.cursor.write).toHaveBeenCalledWith(text, onComplete); 44 | }); 45 | 46 | it('should set line as active', () => { 47 | const line = new EditorLine(1); 48 | line.cursor.setActive = jest.fn(); 49 | line.setActive(); 50 | expect(line.cursor.setActive).toHaveBeenCalled(); 51 | }); 52 | 53 | it('should set line as inactive', () => { 54 | const line = new EditorLine(1); 55 | line.cursor.setInactive = jest.fn(); 56 | line.setInactive(); 57 | expect(line.cursor.setInactive).toHaveBeenCalled(); 58 | }); 59 | 60 | }); 61 | -------------------------------------------------------------------------------- /src/scripts/components/player/player.js: -------------------------------------------------------------------------------- 1 | import { Desktop } from '../desktop/desktop'; 2 | 3 | export class Player { 4 | constructor(container, steps){ 5 | this.container = container; 6 | this.steps = steps; 7 | this.desktop = new Desktop(container); 8 | this.setCurrentStepNumber(0); 9 | } 10 | play(){ 11 | const { desktop, steps } = this; 12 | return new Promise(resolve => playSteps(this, desktop, steps, resolve)); 13 | } 14 | getCurrentStepNumber(){ 15 | return this.currentStepNumber; 16 | } 17 | setCurrentStepNumber(stepNumber){ 18 | this.currentStepNumber = stepNumber; 19 | } 20 | } 21 | 22 | function playSteps(player, desktop, steps, onComplete){ 23 | let currentStepNumber = player.getCurrentStepNumber(); 24 | if(currentStepNumber < steps.length){ 25 | const step = steps[currentStepNumber]; 26 | playStep(desktop, step, () => { 27 | player.setCurrentStepNumber(currentStepNumber + 1); 28 | playSteps(player, desktop, steps, onComplete); 29 | }, step.onCompleteDelay); 30 | } else { 31 | onComplete(); 32 | } 33 | } 34 | 35 | function playStep(desktop, step, onComplete, onCompleteDelay = 0){ 36 | getApplication(desktop, step.app, step.options, application => { 37 | const callback = () => setTimeout(onComplete, onCompleteDelay); 38 | if(step.action) return application[step.action](step.params, callback); 39 | return callback(); 40 | }); 41 | } 42 | 43 | function getApplication(desktop, app, options, onGetApplication){ 44 | const application = desktop.openApplication(app, options); 45 | const onMaximizeApplication = () => onGetApplication(application); 46 | if(application.isMaximized) { 47 | onGetApplication(application); 48 | } else if(application.inanimate) { 49 | desktop.minimizeAllApplications(); 50 | desktop.maximizeApplication(application, onMaximizeApplication) 51 | } else { 52 | desktop.minimizeAllApplications(() => { 53 | desktop.maximizeApplication(application, onMaximizeApplication); 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/scripts/components/player/player.test.js: -------------------------------------------------------------------------------- 1 | import { Player } from './player'; 2 | import { Desktop } from '../desktop/desktop'; 3 | import { DesktopMock } from '@mocks/desktop-mock'; 4 | 5 | jest.mock('../desktop/desktop'); 6 | Desktop.mockImplementation(DesktopMock); 7 | 8 | describe('Player Component', () => { 9 | let container; 10 | 11 | jest.useFakeTimers(); 12 | 13 | function instantiatePlayer(steps = []){ 14 | container = document.createElement('div'); 15 | return new Player(container, steps); 16 | } 17 | 18 | function mockMaximizedTerminalApplication(){ 19 | return { 20 | isMaximized: true, 21 | command: jest.fn((params, onComplete) => { onComplete(); }), 22 | respond: jest.fn((params, onComplete) => { onComplete(); }) 23 | }; 24 | } 25 | 26 | it('should set steps on instantiate', () => { 27 | const options = {windowTitle: 'Atom'}; 28 | const steps = [{app: 'editor', options}]; 29 | const player = instantiatePlayer(steps); 30 | expect(player.steps).toEqual(steps); 31 | }); 32 | 33 | it('should build desktop passing player container on instantiate', () => { 34 | const player = instantiatePlayer(); 35 | expect(Desktop).toHaveBeenCalledWith(container); 36 | expect(player.desktop).toBeDefined(); 37 | }); 38 | 39 | it('should set current step number number as zero on instantiate', () => { 40 | const player = instantiatePlayer(); 41 | expect(player.currentStepNumber).toEqual(0); 42 | }); 43 | 44 | it('should open step application when playing some step', () => { 45 | const options = {windowTitle: 'Atom'}; 46 | const steps = [{app: 'editor', options}]; 47 | const player = instantiatePlayer(steps); 48 | player.desktop.openApplication.mockReturnValue({a:1}); 49 | player.play(); 50 | expect(player.desktop.openApplication).toHaveBeenCalledWith('editor', options); 51 | }); 52 | 53 | it('should minimize all applications and maximize step application if step application is not maximized when playing some step', () => { 54 | const steps = [{app: 'editor'}]; 55 | const player = instantiatePlayer(steps); 56 | const application = {}; 57 | player.desktop.openApplication.mockReturnValue(application); 58 | player.desktop.minimizeAllApplications.mockImplementation(onComplete => { onComplete(); }); 59 | player.desktop.maximizeApplication.mockImplementation((application, onComplete) => { onComplete(); }); 60 | player.play(); 61 | expect(typeof player.desktop.minimizeAllApplications.mock.calls[0][0]).toEqual('function'); 62 | expect(player.desktop.maximizeApplication.mock.calls[0][0]).toEqual(application); 63 | expect(typeof player.desktop.maximizeApplication.mock.calls[0][1]).toEqual('function'); 64 | }); 65 | 66 | it('should minimize all applications and maximize step application synchronously if step application to be open is inanimate', () => { 67 | const steps = [{app: 'editor'}]; 68 | const player = instantiatePlayer(steps); 69 | const application = { inanimate: true }; 70 | player.desktop.openApplication.mockReturnValue(application); 71 | player.play(); 72 | expect(player.desktop.minimizeAllApplications).toHaveBeenCalled(); 73 | expect(player.desktop.maximizeApplication.mock.calls[0][0]).toEqual(application); 74 | expect(typeof player.desktop.maximizeApplication.mock.calls[0][1]).toEqual('function'); 75 | }); 76 | 77 | it('should neither minimize or maximize any application if step application is already maximized when playing some step', () => { 78 | const steps = [{app: 'editor'}]; 79 | const player = instantiatePlayer(steps); 80 | const application = {isMaximized: true}; 81 | player.desktop.openApplication.mockReturnValue(application); 82 | player.play(); 83 | expect(player.desktop.minimizeAllApplications).not.toHaveBeenCalled(); 84 | expect(player.desktop.maximizeApplication).not.toHaveBeenCalled(); 85 | }); 86 | 87 | it('should play some step action with its parameters', () => { 88 | const params = {codeSample: 'console.log("test!")'}; 89 | const steps = [{app: 'editor', action: 'write', params}]; 90 | const player = instantiatePlayer(steps); 91 | const application = {isMaximized: true, write: jest.fn()}; 92 | player.desktop.openApplication.mockReturnValue(application); 93 | player.play(); 94 | expect(application.write.mock.calls[0][0]).toEqual(params); 95 | expect(typeof application.write.mock.calls[0][1]).toEqual('function'); 96 | }); 97 | 98 | it('should increment current step number after play some step', () => { 99 | const steps = [ 100 | {app: 'terminal', action: 'command'}, 101 | {app: 'terminal', action: 'respond'} 102 | ]; 103 | const player = instantiatePlayer(steps); 104 | const application = mockMaximizedTerminalApplication(); 105 | player.desktop.openApplication.mockReturnValue(application); 106 | player.play(); 107 | jest.runOnlyPendingTimers(); 108 | expect(player.currentStepNumber).toEqual(1); 109 | jest.runOnlyPendingTimers(); 110 | expect(player.currentStepNumber).toEqual(2); 111 | }); 112 | 113 | it('should optionally delay to play the next step', () => { 114 | const steps = [ 115 | {app: 'terminal', action: 'command', params: {}, onCompleteDelay: 500}, 116 | {app: 'terminal', action: 'respond', params: {}} 117 | ]; 118 | const player = instantiatePlayer(steps); 119 | const application = mockMaximizedTerminalApplication(); 120 | player.desktop.openApplication.mockReturnValue(application); 121 | player.play(); 122 | expect(application.command).toHaveBeenCalled(); 123 | jest.advanceTimersByTime(499); 124 | expect(application.respond).not.toHaveBeenCalled(); 125 | jest.advanceTimersByTime(1); 126 | expect(application.respond).toHaveBeenCalled(); 127 | }); 128 | 129 | it('should return a promise when playing steps', () => { 130 | const steps = []; 131 | const player = instantiatePlayer(steps); 132 | const promise = player.play(); 133 | expect(promise.then).toBeDefined(); 134 | }); 135 | 136 | it('should resolve promise when finish to play steps', done => { 137 | let resolved; 138 | const steps = [ 139 | {app: 'terminal', action: 'command', params: {}}, 140 | {app: 'terminal', action: 'respond', params: {}} 141 | ]; 142 | const player = instantiatePlayer(steps); 143 | const application = mockMaximizedTerminalApplication(); 144 | player.desktop.openApplication.mockReturnValue(application); 145 | player.play().then(() => { 146 | resolved = true; 147 | expect(resolved).toEqual(true); 148 | done() 149 | }); 150 | jest.runAllTimers(); 151 | }); 152 | 153 | }); 154 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-application/terminal-application.js: -------------------------------------------------------------------------------- 1 | import textService from '../../services/text/text'; 2 | import { Application } from '../application/application'; 3 | import { TerminalCommandLine } from '../terminal-command-line/terminal-command-line'; 4 | import { TerminalResponseLine } from '../terminal-response-line/terminal-response-line'; 5 | 6 | export class TerminalApplication extends Application { 7 | constructor(container, options = {}){ 8 | super('terminal', options); 9 | this.container = container; 10 | this.container.appendChild(this.element); 11 | this.setCommandLines([]); 12 | this.configOptions(options); 13 | } 14 | configOptions(options){ 15 | this.setWindowTitle(buildWindowTitle(options)); 16 | this.setPromptString(buildPromptString(options)); 17 | } 18 | setCommandLines(lines){ 19 | this.commandLines = lines; 20 | } 21 | setPromptString(string){ 22 | this.promptString = string; 23 | } 24 | command({ command, promptString }, onComplete){ 25 | if(promptString) 26 | this.setPromptString(promptString); 27 | setLastCommandLineWrittenAsInactive(this.commandLines); 28 | writeCommandLine(this, command, onComplete); 29 | } 30 | respond({ response }, onComplete){ 31 | const responseLines = textService.removeBlankFirstLine(response); 32 | for(let i = 0; i < responseLines.length; i++) 33 | this.addContent(buildResponseLineElement(responseLines[i])); 34 | onComplete(); 35 | } 36 | } 37 | 38 | function buildWindowTitle(options){ 39 | return options.windowTitle || 'bash'; 40 | } 41 | 42 | function buildPromptString(options){ 43 | return options.promptString || '~/demo $'; 44 | } 45 | 46 | function setLastCommandLineWrittenAsInactive(commandLines){ 47 | if(commandLines.length) 48 | commandLines[commandLines.length - 1].setInactive(); 49 | } 50 | 51 | function writeCommandLine(terminalApplication, command, onComplete){ 52 | const line = new TerminalCommandLine(terminalApplication.promptString); 53 | terminalApplication.commandLines.push(line); 54 | terminalApplication.addContent(line.element); 55 | line.setActive(); 56 | line.command(command, onComplete); 57 | } 58 | 59 | function buildResponseLineElement(lineText){ 60 | const line = new TerminalResponseLine(); 61 | line.setText(lineText); 62 | return line.element; 63 | } 64 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-application/terminal-application.test.js: -------------------------------------------------------------------------------- 1 | import { TerminalApplication } from './terminal-application'; 2 | import { TerminalCommandLine } from '../terminal-command-line/terminal-command-line'; 3 | import { TerminalResponseLine } from '../terminal-response-line/terminal-response-line'; 4 | import { TerminalCommandLineMock, terminalCommandLineInstanceMock } from '@mocks/terminal-command-line-mock'; 5 | import { TerminalResponseLineMock, terminalResponseLineInstanceMock } from '@mocks/terminal-response-line-mock'; 6 | import textService from '../../services/text/text'; 7 | 8 | jest.mock('../terminal-command-line/terminal-command-line'); 9 | TerminalCommandLine.mockImplementation(TerminalCommandLineMock); 10 | 11 | jest.mock('../terminal-response-line/terminal-response-line'); 12 | TerminalResponseLine.mockImplementation(TerminalResponseLineMock); 13 | 14 | describe('Terminal Application Component', () => { 15 | 16 | function instantiateTerminalApplication(options){ 17 | const container = document.createElement('div'); 18 | return new TerminalApplication(container, options); 19 | } 20 | 21 | beforeEach(() => { 22 | spyOn(textService, 'removeBlankFirstLine').and.callFake(value => [value]); 23 | }); 24 | 25 | it('should build terminal application wrapper on instantiate', () => { 26 | const application = instantiateTerminalApplication(); 27 | expect(application.element.classList[0]).toEqual('terminal-application'); 28 | }); 29 | 30 | it('should build lines wrapper on instantiate', () => { 31 | const application = instantiateTerminalApplication(); 32 | const linesWrapper = application.element.querySelector('ul'); 33 | expect(linesWrapper).toBeDefined(); 34 | }); 35 | 36 | it('should config window title with default window title if no window title option is given', () => { 37 | const application = instantiateTerminalApplication(); 38 | expect(application.windowTitle).toEqual('bash'); 39 | }); 40 | 41 | it('should config window title with the window title given as option', () => { 42 | const windowTitle = 'zsh'; 43 | const application = instantiateTerminalApplication({ windowTitle }); 44 | expect(application.windowTitle).toEqual(windowTitle); 45 | }); 46 | 47 | it('should config prompt string with default prompt string if no prompt string option is given', () => { 48 | const application = instantiateTerminalApplication(); 49 | expect(application.promptString).toEqual('~/demo $'); 50 | }); 51 | 52 | it('should config prompt string with the prompt string given as option', () => { 53 | const promptString = '> $'; 54 | const application = instantiateTerminalApplication({ promptString }); 55 | expect(application.promptString).toEqual(promptString); 56 | }); 57 | 58 | it('should build a command line with current defined prompt string on command', () => { 59 | const application = instantiateTerminalApplication(); 60 | const command = 'npm install'; 61 | const onComplete = jest.fn(); 62 | spyOn(application, 'addContent'); 63 | application.command({ command }, onComplete); 64 | expect(TerminalCommandLine).toHaveBeenCalledWith(application.promptString); 65 | }); 66 | 67 | it('should add a command line to terminal application on command', () => { 68 | const application = instantiateTerminalApplication(); 69 | const command = 'npm install'; 70 | const onComplete = jest.fn(); 71 | spyOn(application, 'addContent'); 72 | application.command({ command }, onComplete); 73 | expect(application.addContent).toHaveBeenCalledWith(terminalCommandLineInstanceMock.element); 74 | }); 75 | 76 | it('should write some command', () => { 77 | const application = instantiateTerminalApplication(); 78 | const command = 'npm install'; 79 | const onComplete = jest.fn(); 80 | spyOn(application, 'addContent'); 81 | application.command({ command }, onComplete); 82 | expect(terminalCommandLineInstanceMock.command).toHaveBeenCalledWith(command, onComplete); 83 | }); 84 | 85 | it('should allow customizing prompt string when writing some command', () => { 86 | const application = instantiateTerminalApplication({ promptString: '~/demo $' }); 87 | const command = 'npm install'; 88 | const onComplete = jest.fn(); 89 | const customPromptString = '>'; 90 | spyOn(application, 'addContent'); 91 | application.command({ command, promptString: customPromptString }, onComplete); 92 | expect(application.promptString).toEqual(customPromptString); 93 | }); 94 | 95 | it('should set line as active on command', () => { 96 | const application = instantiateTerminalApplication(); 97 | spyOn(application, 'addContent'); 98 | application.command({ command: 'npm install' }, jest.fn()); 99 | expect(terminalCommandLineInstanceMock.setActive).toHaveBeenCalled(); 100 | }); 101 | 102 | it('should set last line commanded as inactive on command', () => { 103 | const application = instantiateTerminalApplication(); 104 | spyOn(application, 'addContent'); 105 | application.command({ command: 'first command' }, jest.fn()); 106 | const lastLineCommanded = application.commandLines[application.commandLines.length - 1]; 107 | lastLineCommanded.setInactive = jest.fn(); 108 | application.command({ command: 'second command' }, jest.fn()); 109 | expect(lastLineCommanded.setInactive).toHaveBeenCalled(); 110 | }); 111 | 112 | it('should build a response line on respond', () => { 113 | const application = instantiateTerminalApplication(); 114 | const response = 'Successfully installed!'; 115 | const onComplete = jest.fn(); 116 | spyOn(application, 'addContent'); 117 | application.respond({ response }, onComplete); 118 | expect(TerminalResponseLine).toHaveBeenCalled(); 119 | expect(terminalResponseLineInstanceMock.setText).toHaveBeenCalledWith(response); 120 | }); 121 | 122 | it('should add a response line to terminal application on respond', () => { 123 | const application = instantiateTerminalApplication(); 124 | const response = 'Successfully installed!'; 125 | const onComplete = jest.fn(); 126 | spyOn(application, 'addContent'); 127 | application.respond({ response }, onComplete); 128 | expect(application.addContent).toHaveBeenCalledWith(terminalResponseLineInstanceMock.element); 129 | }); 130 | 131 | it('should execute complete callback after finish writing response', () => { 132 | const application = instantiateTerminalApplication(); 133 | const response = 'Successfully installed!'; 134 | const onComplete = jest.fn(); 135 | spyOn(application, 'addContent'); 136 | application.respond({ response }, onComplete); 137 | expect(onComplete).toHaveBeenCalled(); 138 | }); 139 | 140 | }); 141 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-command-line/terminal-command-line.html: -------------------------------------------------------------------------------- 1 |
2 |
5 |
6 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-command-line/terminal-command-line.js: -------------------------------------------------------------------------------- 1 | import '@styles/terminal-command-line.styl'; 2 | import { Cursor } from '../cursor/cursor'; 3 | import { TerminalLine } from '../terminal-line/terminal-line'; 4 | import domService from '../../services/dom/dom'; 5 | import template from './terminal-command-line.html'; 6 | 7 | export class TerminalCommandLine extends TerminalLine { 8 | constructor(promptString){ 9 | super(); 10 | this.cursor = new Cursor(); 11 | this.setContent(domService.parseHtml(template)); 12 | this.setPromptString(promptString); 13 | getTextElement(this.element).appendChild(this.cursor.element); 14 | } 15 | setPromptString(promptString){ 16 | const container = this.element.querySelector('[data-terminal-command-line-prompt-string]'); 17 | container.appendChild(domService.parseHtml(promptString)); 18 | } 19 | command(text, onComplete){ 20 | this.cursor.write(text, onComplete); 21 | } 22 | setActive(){ 23 | this.cursor.setActive(); 24 | } 25 | setInactive(){ 26 | this.cursor.setInactive(); 27 | } 28 | } 29 | 30 | function getTextElement(lineElement){ 31 | return lineElement.querySelector('[data-terminal-command-line-text]'); 32 | } 33 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-command-line/terminal-command-line.test.js: -------------------------------------------------------------------------------- 1 | import { Cursor } from '../cursor/cursor'; 2 | import { TerminalCommandLine } from './terminal-command-line'; 3 | import { CursorMock } from '@mocks/cursor-mock'; 4 | 5 | jest.mock('../cursor/cursor'); 6 | Cursor.mockImplementation(CursorMock); 7 | 8 | describe('Terminal Command Line Component', () => { 9 | 10 | it('should set line content in terminal line on instantiate', () => { 11 | const line = new TerminalCommandLine(); 12 | const wrapper = line.element; 13 | const promptStringElement = line.element.querySelector('[data-terminal-command-line-prompt-string]'); 14 | const textElement = line.element.querySelector('[data-terminal-command-line-text]'); 15 | expect(wrapper.classList[0]).toEqual('terminal-line'); 16 | expect(promptStringElement.classList[0]).toEqual('terminal-command-line-prompt-string'); 17 | expect(textElement.classList[0]).toEqual('terminal-command-line-text'); 18 | }); 19 | 20 | it('should set prompt string as plain text', () => { 21 | const line = new TerminalCommandLine('>'); 22 | const promptStringElement = line.element.querySelector('[data-terminal-command-line-prompt-string]'); 23 | expect(promptStringElement.innerHTML.trim()).toEqual('>'); 24 | }); 25 | 26 | it('should set prompt string as html', () => { 27 | const line = new TerminalCommandLine('!'); 28 | const promptStringElement = line.element.querySelector('[data-terminal-command-line-prompt-string]'); 29 | expect(promptStringElement.querySelector('span').innerHTML).toEqual('!'); 30 | }); 31 | 32 | it('should append a cursor element in terminal line text element on instantiate', () => { 33 | const line = new TerminalCommandLine(); 34 | const cursorElement = line.element.querySelectorAll('[data-terminal-command-line-text] > span'); 35 | expect(cursorElement.length).toEqual(1); 36 | }); 37 | 38 | it('should write some command using cursor', () => { 39 | const line = new TerminalCommandLine(); 40 | const command = 'npm install'; 41 | const onComplete = jest.fn(); 42 | line.command(command, onComplete); 43 | expect(line.cursor.write).toHaveBeenCalledWith(command, onComplete); 44 | }); 45 | 46 | it('should set line as active', () => { 47 | const line = new TerminalCommandLine(); 48 | line.cursor.setActive = jest.fn(); 49 | line.setActive(); 50 | expect(line.cursor.setActive).toHaveBeenCalled(); 51 | }); 52 | 53 | it('should set line as inactive', () => { 54 | const line = new TerminalCommandLine(); 55 | line.cursor.setInactive = jest.fn(); 56 | line.setInactive(); 57 | expect(line.cursor.setInactive).toHaveBeenCalled(); 58 | }); 59 | 60 | }); 61 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-line/terminal-line.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-line/terminal-line.js: -------------------------------------------------------------------------------- 1 | import '@styles/terminal-line.styl'; 2 | import domService from '../../services/dom/dom'; 3 | import template from './terminal-line.html'; 4 | 5 | export class TerminalLine { 6 | constructor(){ 7 | this.element = domService.parseHtml(template); 8 | } 9 | setContent(html){ 10 | this.element.append(html); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-line/terminal-line.test.js: -------------------------------------------------------------------------------- 1 | import { TerminalLine } from './terminal-line'; 2 | 3 | describe('Terminal Line Component', () => { 4 | 5 | it('should build terminal line on instantiate', () => { 6 | const line = new TerminalLine(); 7 | expect(line.element.classList[0]).toEqual('terminal-line'); 8 | }); 9 | 10 | it('should set content', () => { 11 | const line = new TerminalLine(); 12 | const paragraph = document.createElement('p'); 13 | paragraph.innerText = 'content'; 14 | line.setContent(paragraph); 15 | expect(line.element.querySelector('p').innerText).toEqual('content'); 16 | }); 17 | 18 | }); 19 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-response-line/terminal-response-line.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-response-line/terminal-response-line.js: -------------------------------------------------------------------------------- 1 | import '@styles/terminal-response-line.styl'; 2 | import { TerminalLine } from '../terminal-line/terminal-line'; 3 | import domService from '../../services/dom/dom'; 4 | import template from './terminal-response-line.html'; 5 | 6 | export class TerminalResponseLine extends TerminalLine { 7 | constructor(){ 8 | super(); 9 | this.setContent(domService.parseHtml(template)); 10 | } 11 | setText(text){ 12 | const textContainer = this.element.querySelector('[data-terminal-response-line-text]'); 13 | return domService.containsClosingHtmlTag(text) ? 14 | appendHtml(textContainer, domService.parseHtml(text)) : 15 | appendText(textContainer, text); 16 | } 17 | } 18 | 19 | function appendHtml(container, html){ 20 | addElementCssClass(container, 'terminal-response-line-html-text'); 21 | container.appendChild(html); 22 | } 23 | 24 | function appendText(container, text){ 25 | addElementCssClass(container, 'terminal-response-line-plain-text'); 26 | container.innerText = text; 27 | } 28 | 29 | function addElementCssClass(element, cssClass){ 30 | element.classList.add(cssClass); 31 | } 32 | -------------------------------------------------------------------------------- /src/scripts/components/terminal-response-line/terminal-response-line.test.js: -------------------------------------------------------------------------------- 1 | import { TerminalResponseLine } from './terminal-response-line'; 2 | 3 | describe('Terminal Response Line Component', () => { 4 | 5 | it('should build line element on instantiate', () => { 6 | const line = new TerminalResponseLine(); 7 | const wrapper = line.element; 8 | const textElement = line.element.querySelector('[data-terminal-response-line-text]'); 9 | expect(wrapper.classList[0]).toEqual('terminal-line'); 10 | expect(textElement.classList[0]).toEqual('terminal-response-line-text'); 11 | }); 12 | 13 | it('should set line as plain text', () => { 14 | const text = 'hello'; 15 | const line = new TerminalResponseLine(); 16 | line.setText(text); 17 | const textElement = line.element.querySelector('[data-terminal-response-line-text]'); 18 | expect(textElement.innerText).toEqual(text); 19 | }); 20 | 21 | it('should set plain text css class on set line as plain text', () => { 22 | const line = new TerminalResponseLine(); 23 | line.setText(''); 24 | const textElement = line.element.querySelector('[data-terminal-response-line-text]'); 25 | expect(textElement.classList.contains('terminal-response-line-plain-text')).toEqual(true); 26 | }); 27 | 28 | it('should set line as html', () => { 29 | const text = 'hello'; 30 | const line = new TerminalResponseLine(); 31 | line.setText(text); 32 | const textElement = line.element.querySelector('[data-terminal-response-line-text]'); 33 | expect(textElement.querySelector('span').innerHTML).toEqual('hello'); 34 | }); 35 | 36 | it('should set plain text css class on set line as plain text', () => { 37 | const line = new TerminalResponseLine(); 38 | line.setText('hello'); 39 | const textElement = line.element.querySelector('[data-terminal-response-line-text]'); 40 | expect(textElement.classList.contains('terminal-response-line-html-text')).toEqual(true); 41 | }); 42 | 43 | }); 44 | -------------------------------------------------------------------------------- /src/scripts/constants/application.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_APPLICATION_ID = '_default'; 2 | -------------------------------------------------------------------------------- /src/scripts/index.js: -------------------------------------------------------------------------------- 1 | import '@styles/_variables.styl'; 2 | import '@styles/_mixins.styl'; 3 | import fyzer from '@glorious/fyzer'; 4 | import { Player } from './components/player/player'; 5 | 6 | export default class { 7 | constructor(selector){ 8 | this.container = document.querySelector(selector); 9 | this.steps = []; 10 | } 11 | openApp(app, options = {}){ 12 | this.steps.push({ 13 | app, 14 | options, 15 | onCompleteDelay: options.onCompleteDelay 16 | }); 17 | return this; 18 | } 19 | write(codeSample, { onCompleteDelay, ...options } = {}){ 20 | this.steps.push({ 21 | app: 'editor', 22 | action: 'write', 23 | params: { codeSample }, 24 | onCompleteDelay, 25 | options 26 | }); 27 | return this; 28 | } 29 | command(command, { onCompleteDelay, promptString, ...options } = {}){ 30 | this.steps.push({ 31 | app: 'terminal', 32 | action: 'command', 33 | params: { 34 | command, 35 | promptString 36 | }, 37 | onCompleteDelay, 38 | options 39 | }); 40 | return this; 41 | } 42 | respond(response, { onCompleteDelay, ...options } = {}){ 43 | this.steps.push({ 44 | app: 'terminal', 45 | action: 'respond', 46 | params: { response }, 47 | onCompleteDelay, 48 | options 49 | }); 50 | return this; 51 | } 52 | end(){ 53 | return new Promise(resolve => { 54 | const { container, steps } = this; 55 | awaitContainerAppearsAboveTheFoldToPlay(container, steps, resolve); 56 | }); 57 | } 58 | } 59 | 60 | function awaitContainerAppearsAboveTheFoldToPlay(container, steps, resolve){ 61 | const subscriptionId = fyzer.subscribe(container, () => { 62 | const player = new Player(container, steps); 63 | fyzer.unsubscribe(subscriptionId); 64 | player.play().then(resolve); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /src/scripts/index.test.js: -------------------------------------------------------------------------------- 1 | import fyzer from '@glorious/fyzer'; 2 | import GDemo from './index'; 3 | import { Player } from './components/player/player'; 4 | import { PlayerMock, playerInstanceMock } from '@mocks/player-mock'; 5 | 6 | jest.useFakeTimers(); 7 | jest.mock('./components/player/player'); 8 | Player.mockImplementation(PlayerMock); 9 | 10 | describe('Glorious Demo class', () => { 11 | function instantiateGDemo(){ 12 | return new GDemo('[data-container]'); 13 | } 14 | 15 | function simulateElementAboveTheFold(){ 16 | fyzer.subscribe = jest.fn((element, callback) => { 17 | setTimeout(callback); 18 | return '123'; 19 | }); 20 | } 21 | 22 | beforeEach(() => { 23 | spyOn(document, 'querySelector').and.returnValue({}); 24 | fyzer.unsubscribe = jest.fn(); 25 | }); 26 | 27 | it('should build its container on instantiate', () => { 28 | const gDemo = instantiateGDemo(); 29 | expect(document.querySelector).toHaveBeenCalledWith('[data-container]'); 30 | expect(gDemo.container).toEqual({}); 31 | }); 32 | 33 | it('should initialize steps as an empty array on instantiate', () => { 34 | const gDemo = instantiateGDemo(); 35 | expect(gDemo.steps).toEqual([]); 36 | }); 37 | 38 | it('should not play animation if container element is below the page fold', () => { 39 | const gDemo = instantiateGDemo(); 40 | gDemo.openApp('editor').end(); 41 | expect(playerInstanceMock.play).not.toHaveBeenCalled(); 42 | }); 43 | 44 | it('should play animation if container element is above the page fold', () => { 45 | simulateElementAboveTheFold(); 46 | const gDemo = instantiateGDemo(); 47 | gDemo.openApp('editor').end(); 48 | jest.runOnlyPendingTimers(); 49 | expect(playerInstanceMock.play).toHaveBeenCalled(); 50 | expect(fyzer.unsubscribe).toHaveBeenCalledWith('123'); 51 | }); 52 | 53 | it('should play steps', () => { 54 | simulateElementAboveTheFold(); 55 | const gDemo = instantiateGDemo(); 56 | gDemo 57 | .openApp('editor') 58 | .write('console.log("hello!");') 59 | .openApp('terminal') 60 | .command('node demo.js') 61 | .respond('hello!') 62 | .end(); 63 | jest.runOnlyPendingTimers(); 64 | expect(Player).toHaveBeenCalledWith(gDemo.container, [ 65 | {app: 'editor', options: {}, onCompleteDelay: undefined}, 66 | {app: 'editor', action: 'write', params: {codeSample: 'console.log("hello!");'}, options: {}, onCompleteDelay: undefined}, 67 | {app: 'terminal', options: {}, onCompleteDelay: undefined}, 68 | {app: 'terminal', action: 'command', params: {command: 'node demo.js', promptString: undefined}, options: {}, onCompleteDelay: undefined}, 69 | {app: 'terminal', action: 'respond', params: {response: 'hello!'}, options: {}, onCompleteDelay: undefined} 70 | ]); 71 | expect(playerInstanceMock.play).toHaveBeenCalled(); 72 | }); 73 | 74 | it('should play steps with options', () => { 75 | simulateElementAboveTheFold(); 76 | const gDemo = instantiateGDemo(); 77 | gDemo 78 | .openApp('editor', {id: 'editor1', windowTitle: 'atom', onCompleteDelay: 200}) 79 | .write('console.log("hello!");', {id: 'editor1', onCompleteDelay: 300}) 80 | .openApp('terminal', {id: 'terminal1', windowTitle: 'bash'}) 81 | .command('node demo.js', {id: 'terminal1', promptString: '>', onCompleteDelay: 400}) 82 | .respond('hello!', {id: 'terminal1', onCompleteDelay: 500}) 83 | .end(); 84 | jest.runOnlyPendingTimers(); 85 | expect(Player).toHaveBeenCalledWith(gDemo.container, [ 86 | {app: 'editor', options: {id: 'editor1', windowTitle: 'atom', onCompleteDelay: 200}, onCompleteDelay: 200}, 87 | {app: 'editor', action: 'write', params: {codeSample: 'console.log("hello!");'}, options: {id: 'editor1'}, onCompleteDelay: 300}, 88 | {app: 'terminal', options: {id: 'terminal1', windowTitle: 'bash'}, onCompleteDelay: undefined }, 89 | {app: 'terminal', action: 'command', params: {command: 'node demo.js', promptString: '>'}, options: {id: 'terminal1'}, onCompleteDelay: 400}, 90 | {app: 'terminal', action: 'respond', params: {response: 'hello!'}, options: {id: 'terminal1'}, onCompleteDelay: 500} 91 | ]); 92 | expect(playerInstanceMock.play).toHaveBeenCalled(); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/scripts/services/dom/dom.js: -------------------------------------------------------------------------------- 1 | const _public = {}; 2 | 3 | _public.parseHtml = htmlString => { 4 | const parser = new DOMParser(); 5 | const doc = parser.parseFromString(htmlString, 'text/html'); 6 | return doc.querySelector('body').firstChild; 7 | }; 8 | 9 | _public.wrapHtmlStringInHtmlTag = (htmlString, tagName) => { 10 | return `<${tagName}>${htmlString}`; 11 | }; 12 | 13 | _public.clearNodeContent = node => { 14 | node.innerHTML = ''; 15 | return node; 16 | }; 17 | 18 | _public.containsClosingHtmlTag = string => { 19 | const regex = new RegExp('', 'gm'); 20 | return regex.test(string); 21 | }; 22 | 23 | _public.isHtmlNodeTypeText = node => { 24 | return node && node.nodeName.toLowerCase() == '#text'; 25 | }; 26 | 27 | export default _public; 28 | -------------------------------------------------------------------------------- /src/scripts/services/dom/dom.test.js: -------------------------------------------------------------------------------- 1 | import domService from './dom'; 2 | 3 | describe('DOM Service', () => { 4 | 5 | function createElement(tagName){ 6 | return document.createElement(tagName); 7 | } 8 | 9 | it('should parse an html from string', () => { 10 | const element = domService.parseHtml('

Hello World!

'); 11 | const span = element.querySelector('span'); 12 | expect(span.innerHTML).toEqual('World'); 13 | }); 14 | 15 | it('should wrap html string in html tag', () => { 16 | const htmlString = '

Hello

'; 17 | const wrappedHtmlString = domService.wrapHtmlStringInHtmlTag(htmlString, 'div'); 18 | expect(wrappedHtmlString).toEqual(`
${htmlString}
`); 19 | }); 20 | 21 | it('should clear some node content', () => { 22 | const node = createElement('div'); 23 | node.innertText = 'Rafael'; 24 | const clearedNode = domService.clearNodeContent(node); 25 | expect(clearedNode.innerHTML).toEqual(''); 26 | }); 27 | 28 | it('should detect some closing html tag in a string', () => { 29 | const string = 'Text'; 30 | expect(domService.containsClosingHtmlTag(string)).toEqual(true); 31 | }); 32 | 33 | it('should detect some closing html tag in a multi line string', () => { 34 | const string = ` 35 | 36 | Text 37 | 38 | `; 39 | expect(domService.containsClosingHtmlTag(string)).toEqual(true); 40 | }); 41 | 42 | it('should not detect some closing html tag when string does not contain it', () => { 43 | const string = 'No Closing Tags'; 44 | expect(domService.containsClosingHtmlTag(string)).toEqual(false); 45 | }); 46 | 47 | it('should identify text html nodes', () => { 48 | const notTextNode = createElement('div'); 49 | notTextNode.append('Some text'); 50 | const textNode = Array.from(notTextNode.childNodes)[0]; 51 | expect(domService.isHtmlNodeTypeText(notTextNode)).toEqual(false); 52 | expect(domService.isHtmlNodeTypeText(textNode)).toEqual(true); 53 | }); 54 | 55 | }); 56 | -------------------------------------------------------------------------------- /src/scripts/services/text/text.js: -------------------------------------------------------------------------------- 1 | const _public = {}; 2 | 3 | _public.toKebabCase = text => { 4 | return text.toLowerCase().replace(' ', '-'); 5 | }; 6 | 7 | _public.isEmptyString = string => { 8 | return string.trim() === ''; 9 | }; 10 | 11 | _public.removeBlankFirstLine = text => { 12 | const lines = text.split('\n'); 13 | if(lines[0].trim() === '') 14 | lines.splice(0,1); 15 | return lines; 16 | }; 17 | 18 | export default _public; 19 | -------------------------------------------------------------------------------- /src/scripts/services/text/text.test.js: -------------------------------------------------------------------------------- 1 | import textService from './text'; 2 | 3 | describe('Text Service', () => { 4 | 5 | it('should transform some text in kebab case', () => { 6 | const text = textService.toKebabCase('Editor Application'); 7 | expect(text).toEqual('editor-application'); 8 | }); 9 | 10 | it('should identify an empty string', () => { 11 | expect(textService.isEmptyString('')).toEqual(true); 12 | }); 13 | 14 | it('should identify an non empty string', () => { 15 | expect(textService.isEmptyString('application')).toEqual(false); 16 | }); 17 | 18 | it('should remove first line when it\'s empty', () => { 19 | const text = ` 20 | second line 21 | third line`; 22 | expect(textService.removeBlankFirstLine(text)).toEqual([ 23 | 'second line', 24 | 'third line' 25 | ]); 26 | }); 27 | 28 | it('should not remove first line when it\'s not empty', () => { 29 | const text = 'first line'; 30 | expect(textService.removeBlankFirstLine(text)).toEqual(['first line']); 31 | }); 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /src/scripts/services/type-html-text/type-html-text.js: -------------------------------------------------------------------------------- 1 | import domService from '../dom/dom'; 2 | import typePlainTextService from '../type-plain-text/type-plain-text'; 3 | 4 | const _public = {}; 5 | 6 | _public.type = (container, htmlString, onComplete) => { 7 | const nodes = buildHtmlNodes(htmlString); 8 | typeHtmlNodes(container, nodes, onComplete); 9 | }; 10 | 11 | function buildHtmlNodes(htmlString){ 12 | const wrappedHtmlString = domService.wrapHtmlStringInHtmlTag(htmlString, 'span'); 13 | const html = domService.parseHtml(wrappedHtmlString); 14 | return Array.from(html.childNodes); 15 | } 16 | 17 | function typeHtmlNodes(container, nodes, onComplete){ 18 | if(!nodes.length) 19 | return onComplete(); 20 | typeSingleHtmlNode(container, nodes.shift(), () => { 21 | typeHtmlNodes(container, nodes, onComplete); 22 | }); 23 | } 24 | 25 | function typeSingleHtmlNode(container, node, onComplete){ 26 | if(domService.isHtmlNodeTypeText(node)) 27 | typePlainText(node.textContent, container, onComplete); 28 | else 29 | typePlainText(node.textContent, buildSubContainer(container, node), onComplete); 30 | } 31 | 32 | function buildSubContainer(container, node){ 33 | const subContainer = domService.clearNodeContent(node); 34 | container.appendChild(subContainer); 35 | return subContainer; 36 | } 37 | 38 | function typePlainText(text, container, onComplete){ 39 | typePlainTextService.type(container, text, onComplete); 40 | } 41 | 42 | export default _public; 43 | -------------------------------------------------------------------------------- /src/scripts/services/type-html-text/type-html-text.test.js: -------------------------------------------------------------------------------- 1 | import typePlainTextService from '../type-plain-text/type-plain-text'; 2 | import typeHtmlTextService from './type-html-text'; 3 | 4 | describe('Type Html Text Service', () => { 5 | function createElement(tagName){ 6 | return document.createElement(tagName); 7 | } 8 | 9 | beforeEach(() => { 10 | spyOn(typePlainTextService, 'type').and.callFake((container, text, onComplete) => { 11 | onComplete(); 12 | }); 13 | }); 14 | 15 | it('should type some html text', () => { 16 | const container = createElement('div'); 17 | const htmlString = 'some text'; 18 | typeHtmlTextService.type(container, htmlString, jest.fn()); 19 | expect(typePlainTextService.type.calls.allArgs()[0][0]).toEqual(container); 20 | expect(typePlainTextService.type.calls.allArgs()[0][1]).toEqual('some '); 21 | expect(typeof typePlainTextService.type.calls.allArgs()[0][2]).toEqual('function'); 22 | expect(typePlainTextService.type.calls.allArgs()[1][0]).toEqual(createElement('span')); 23 | expect(typePlainTextService.type.calls.allArgs()[1][1]).toEqual('text'); 24 | expect(typeof typePlainTextService.type.calls.allArgs()[1][2]).toEqual('function'); 25 | }); 26 | 27 | it('should type some html text containing special character', () => { 28 | const container = createElement('div'); 29 | const htmlString = 'some <'; 30 | typeHtmlTextService.type(container, htmlString, jest.fn()); 31 | expect(typePlainTextService.type.calls.allArgs()[1][1]).toEqual('<'); 32 | }); 33 | 34 | it('should type some html text containing blank lines', () => { 35 | const container = createElement('div'); 36 | const htmlString = `first 37 | 38 | second`; 39 | typeHtmlTextService.type(container, htmlString, jest.fn()); 40 | expect(typePlainTextService.type.calls.allArgs()[0][1]).toEqual('first'); 41 | expect(typePlainTextService.type.calls.allArgs()[2][1]).toEqual('second'); 42 | }); 43 | 44 | it('should execute on complete callback on complete', () => { 45 | const container = createElement('div'); 46 | const htmlString = 'some text'; 47 | const onComplete = jest.fn(); 48 | typeHtmlTextService.type(container, htmlString, onComplete); 49 | expect(onComplete).toHaveBeenCalled(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/scripts/services/type-plain-text/type-plain-text.js: -------------------------------------------------------------------------------- 1 | const _public = {}; 2 | 3 | _public.type = (container, text, onComplete) => { 4 | const letters = text.split(''); 5 | const letter = letters.shift(); 6 | if(letter) 7 | appendLetter(container, letter, letters, onComplete); 8 | else 9 | onComplete(); 10 | }; 11 | 12 | function appendLetter(container, letter, letters, onComplete){ 13 | container.append(letter); 14 | setTimeout(() => { 15 | _public.type(container, letters.join(''), onComplete); 16 | }, 75); 17 | } 18 | 19 | export default _public; 20 | -------------------------------------------------------------------------------- /src/scripts/services/type-plain-text/type-plain-text.test.js: -------------------------------------------------------------------------------- 1 | import typePlainTextService from './type-plain-text'; 2 | 3 | describe('Type Plain Text Service', () => { 4 | 5 | let onCompleteCallbackMock; 6 | 7 | jest.useFakeTimers(); 8 | 9 | function mockOnCompleteCallback(){ 10 | onCompleteCallbackMock = jest.fn(); 11 | } 12 | 13 | beforeEach(() => { 14 | mockOnCompleteCallback(); 15 | }); 16 | 17 | it('should type some plain text', () => { 18 | const containerElement = document.createElement('div'); 19 | const textToBeTyped = 'Typed!'; 20 | typePlainTextService.type(containerElement, textToBeTyped, onCompleteCallbackMock); 21 | jest.runAllTimers(); 22 | expect(containerElement.innerHTML).toEqual(textToBeTyped); 23 | }); 24 | 25 | it('should callback after type some plain text', () => { 26 | const containerElement = document.createElement('div'); 27 | const textToBeTyped = 'Typed!'; 28 | typePlainTextService.type(containerElement, textToBeTyped, onCompleteCallbackMock); 29 | jest.runAllTimers(); 30 | expect(onCompleteCallbackMock).toHaveBeenCalledTimes(1); 31 | }); 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /src/scripts/services/type/type.js: -------------------------------------------------------------------------------- 1 | import domService from '../dom/dom'; 2 | import typePlainTextService from '../type-plain-text/type-plain-text'; 3 | import typeHtmlTextService from '../type-html-text/type-html-text'; 4 | 5 | const _public = {}; 6 | 7 | const TYPING_CSS_CLASS = 'is-typing'; 8 | 9 | _public.type = (container, text, onComplete) => { 10 | handleIsTypingCssClass(container, 'add', TYPING_CSS_CLASS); 11 | getSpecificTypeService(text).type(container, text, () => { 12 | handleIsTypingCssClass(container, 'remove', TYPING_CSS_CLASS); 13 | onComplete(); 14 | }); 15 | }; 16 | 17 | function handleIsTypingCssClass(element, action, cssClass){ 18 | element.classList[action](cssClass); 19 | } 20 | 21 | function getSpecificTypeService(text){ 22 | if(domService.containsClosingHtmlTag(text)) 23 | return typeHtmlTextService; 24 | return typePlainTextService; 25 | } 26 | 27 | export default _public; 28 | -------------------------------------------------------------------------------- /src/scripts/services/type/type.test.js: -------------------------------------------------------------------------------- 1 | import typeService from './type'; 2 | import domService from '../text/text'; 3 | import typeHtmlTextService from '../type-html-text/type-html-text'; 4 | import typePlainTextService from '../type-plain-text/type-plain-text'; 5 | 6 | describe('Type Service', () => { 7 | function mockContainer(){ 8 | return document.createElement('div'); 9 | } 10 | 11 | function stubServiceType(shouldAbortCompleteCallback){ 12 | typePlainTextService.type = jest.fn((container, text, onComplete) => { 13 | if(!shouldAbortCompleteCallback) 14 | onComplete(); 15 | }); 16 | } 17 | 18 | beforeEach(() => { 19 | typeHtmlTextService.type = jest.fn(); 20 | }); 21 | 22 | it('should type some plain text', () => { 23 | const container = mockContainer(); 24 | const onComplete = jest.fn(); 25 | const text = 'just plain text'; 26 | stubServiceType(); 27 | domService.containsClosingHtmlTag = jest.fn(() => false); 28 | typeService.type(container, text, onComplete); 29 | expect(typePlainTextService.type.mock.calls[0][0]).toEqual(container); 30 | expect(typePlainTextService.type.mock.calls[0][1]).toEqual(text); 31 | expect(typeof typePlainTextService.type.mock.calls[0][2]).toEqual('function'); 32 | }); 33 | 34 | it('should type some html text', () => { 35 | const container = mockContainer(); 36 | const onComplete = jest.fn(); 37 | const text = 'this is bold'; 38 | stubServiceType(); 39 | domService.containsClosingHtmlTag = jest.fn(() => true); 40 | typeService.type(container, text, onComplete); 41 | expect(typeHtmlTextService.type.mock.calls[0][0]).toEqual(container); 42 | expect(typeHtmlTextService.type.mock.calls[0][1]).toEqual(text); 43 | expect(typeof typeHtmlTextService.type.mock.calls[0][2]).toEqual('function'); 44 | }); 45 | 46 | it('should add typing css class on type', () => { 47 | const container = mockContainer(); 48 | const onComplete = jest.fn(); 49 | const text = 'just plain text'; 50 | stubServiceType(true); 51 | domService.containsClosingHtmlTag = jest.fn(() => false); 52 | typeService.type(container, text, onComplete); 53 | expect(container.classList.contains('is-typing')).toEqual(true); 54 | }); 55 | 56 | it('should remove typing css class on type complete', () => { 57 | const container = mockContainer(); 58 | const onComplete = jest.fn(); 59 | const text = 'just plain text'; 60 | stubServiceType(); 61 | domService.containsClosingHtmlTag = jest.fn(() => false); 62 | typeService.type(container, text, onComplete); 63 | expect(container.classList.contains('is-typing')).toEqual(false); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/styles/_mixins.styl: -------------------------------------------------------------------------------- 1 | @require '_variables' 2 | 3 | border-radius($radius) 4 | -webkit-border-radius $radius 5 | -moz-border-radius $radius 6 | border-radius $radius 7 | 8 | box-sizing($size) 9 | -webkit-box-sizing $size 10 | -moz-box-sizing $size 11 | box-sizing $size 12 | 13 | clearfix() 14 | zoom 1 15 | &:after 16 | content " " 17 | display table 18 | clear both 19 | 20 | transition($property, $time) 21 | -webkit-transition $property $time $animation-ease-default 22 | -moz-transition $property $time $animation-ease-default 23 | -o-transition $property $time $animation-ease-default 24 | transition $property $time $animation-ease-default 25 | 26 | transform($property) 27 | -webkit-transform unquote($property) 28 | -moz-transform unquote($property) 29 | -ms-transform unquote($property) 30 | -o-transform unquote($property) 31 | transform unquote($property) 32 | 33 | unselectable() 34 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 35 | -moz-user-select: -moz-none; 36 | -khtml-user-select: none; 37 | -webkit-user-select: none; 38 | -o-user-select: none; 39 | user-select: none; 40 | -------------------------------------------------------------------------------- /src/styles/_variables.styl: -------------------------------------------------------------------------------- 1 | // COLORS 2 | $color-red = #FF4040 3 | $color-yellow = #FFB700 4 | $color-green = #00AE55 5 | $color-blue = #2898DD 6 | $color-grey-dark = #282C34 7 | $color-lightest = #FFFFFF 8 | $color-darkest = #0E141A 9 | 10 | // FONT 11 | $font-size-xxxs = 10px 12 | $font-size-xxs = 12px 13 | $font-size-xs = 14px 14 | $font-size-sm = 16px 15 | $font-size-md = 18px 16 | $font-size-lg = 20px 17 | $font-size-xl = 24px 18 | $font-size-xxl = 36px 19 | $font-size-xxxl = 48px 20 | 21 | // ANIMATION 22 | $animation-duration = 750ms 23 | $animation-ease-default = cubic-bezier(0.080, 0.690, 0.485, 0.990) 24 | -------------------------------------------------------------------------------- /src/styles/application.styl: -------------------------------------------------------------------------------- 1 | @import '_variables' 2 | @import '_mixins' 3 | 4 | $application-action-size = 8px 5 | $application-box-shadow-intensity = 12px 18px 48px 0 6 | $application-box-shadow-color = alpha($color-darkest, .6) 7 | 8 | .application 9 | background-color $color-darkest 10 | color alpha($color-lightest, .5) 11 | font-family 'Helvetica', 'Arial', sans-serif 12 | box-shadow $application-box-shadow-intensity $application-box-shadow-color 13 | overflow hidden 14 | border-radius(6px) 15 | 16 | .application-topbar 17 | position relative 18 | padding 3px 12px 19 | clearfix() 20 | box-sizing(border-box) 21 | 22 | .application-actions-container 23 | position absolute 24 | top 50% 25 | transform('translateY(-50%)') 26 | 27 | .application-actions-container, 28 | .application-action 29 | float left 30 | 31 | .application-action 32 | width $application-action-size 33 | height $application-action-size 34 | border-radius(50%) 35 | &.application-action-close 36 | background-color $color-red 37 | &.application-action-minimize 38 | background-color $color-yellow 39 | &.application-action-maximize 40 | background-color $color-green 41 | & + .application-action 42 | margin-left 6px 43 | 44 | .application-title-container 45 | padding-left 45px 46 | font-size $font-size-xxxs 47 | text-align center 48 | line-height $font-size-lg 49 | white-space nowrap 50 | letter-spacing .5px 51 | 52 | .application-content-container 53 | overflow auto 54 | 55 | .application-line 56 | position relative 57 | padding 0 6px 58 | min-height $font-size-xl 59 | font-size $font-size-xs 60 | line-height $font-size-xl 61 | font-family monospace 62 | clearfix() 63 | -------------------------------------------------------------------------------- /src/styles/cursor.styl: -------------------------------------------------------------------------------- 1 | @import '_variables' 2 | @import '_mixins' 3 | 4 | @keyframes blink 5 | 0% 6 | background-color $color-lightest 7 | 100% 8 | background-color transparent 9 | 10 | $cursor-height = $font-size-xl 11 | 12 | .cursor 13 | display inline-block 14 | height $cursor-height 15 | &.cursor-active 16 | position relative 17 | &:after 18 | position absolute 19 | content ' ' 20 | top 50% 21 | right -3px 22 | width 1px 23 | height 17px 24 | animation .7s blink ease-in-out infinite 25 | transform('translateY(-50%)') 26 | &.is-typing 27 | &:after 28 | right 0 29 | background-color alpha($color-lightest, .5) 30 | animation none 31 | -------------------------------------------------------------------------------- /src/styles/desktop.styl: -------------------------------------------------------------------------------- 1 | @require '_variables' 2 | @require '_mixins' 3 | @require 'application' 4 | 5 | .desktop 6 | position relative 7 | .application 8 | position absolute 9 | top 0 10 | left 0 11 | right 0 12 | opacity 0 13 | &:not(.application-inanimate) 14 | transform('scale(0)') 15 | transition(all, $animation-duration) 16 | box-shadow 2px 3px 8px 0 $application-box-shadow-color 17 | &.application-maximized 18 | opacity 1 19 | box-shadow $application-box-shadow-intensity $application-box-shadow-color 20 | transform('scale(1)') 21 | -------------------------------------------------------------------------------- /src/styles/editor-application.styl: -------------------------------------------------------------------------------- 1 | @import '_variables' 2 | @import '_mixins' 3 | 4 | $editor-application-line-number-width = 36px 5 | 6 | .editor-application 7 | .application-content-container 8 | background-color $color-grey-dark 9 | .application-line-text 10 | padding-left 36px 11 | 12 | .editor-line-number 13 | unselectable() 14 | -------------------------------------------------------------------------------- /src/styles/editor-line.styl: -------------------------------------------------------------------------------- 1 | @require 'application' 2 | @require 'cursor' 3 | 4 | $editor-line-number-width = 25px 5 | 6 | .editor-line 7 | @extend .application-line 8 | 9 | .editor-line-number, 10 | .editor-line-text 11 | margin 0 12 | height $cursor-height 13 | 14 | .editor-line-number 15 | position absolute 16 | top 0 17 | left 0 18 | padding-left 6px 19 | width $editor-line-number-width 20 | text-align right 21 | opacity .5 22 | 23 | .editor-line-text 24 | padding-left $editor-line-number-width * 2 25 | overflow hidden 26 | -------------------------------------------------------------------------------- /src/styles/terminal-command-line.styl: -------------------------------------------------------------------------------- 1 | @require '_variables' 2 | @require 'cursor' 3 | 4 | .terminal-command-line-prompt-string 5 | float left 6 | margin-right 10px 7 | color $color-blue 8 | 9 | .terminal-command-line-text 10 | height $cursor-height 11 | color $color-lightest 12 | -------------------------------------------------------------------------------- /src/styles/terminal-line.styl: -------------------------------------------------------------------------------- 1 | @require 'application' 2 | 3 | .terminal-line 4 | @extend .application-line 5 | & > div 6 | white-space nowrap 7 | -------------------------------------------------------------------------------- /src/styles/terminal-response-line.styl: -------------------------------------------------------------------------------- 1 | .terminal-response-line-text 2 | margin 0 3 | &.terminal-response-line-plain-text 4 | white-space pre 5 | -------------------------------------------------------------------------------- /standalone/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 28 | Standalone: Glorious Demo 29 | 30 | 31 |
32 |

Examples

33 |
34 |

Standard

35 |
36 |
37 |
38 |

Multiple instances

39 |
40 |
41 |
42 |
43 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /webpack.conf.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const project = require('./project.json'); 4 | 5 | module.exports = { 6 | entry: `${__dirname}/${project.scripts.source.entry}`, 7 | output: { 8 | library: 'GDemo', 9 | libraryTarget: 'umd', 10 | libraryExport: 'default', 11 | path: `${__dirname}/${project.scripts.dist.root}` 12 | }, 13 | module: { 14 | rules: [{ 15 | test: /\.(styl|css)$/, 16 | use: [ MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader' ] 17 | }, { 18 | test: /\.html$/, 19 | include: [path.resolve(__dirname,project.scripts.source.root)], 20 | use: 'html-loader' 21 | }, { 22 | test: /\.js$/, 23 | exclude: /node_modules/, 24 | use: 'babel-loader' 25 | }] 26 | }, 27 | resolve: { 28 | alias: { 29 | '@styles': `${__dirname}/${project.styles.source.root}` 30 | } 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /webpack.conf.dev.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const project = require('./project.json'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | devtool: 'inline-source-map', 8 | output: { 9 | filename: project.scripts.dist.filename.dev 10 | }, 11 | plugins: [ 12 | new webpack.SourceMapDevToolPlugin(), 13 | new webpack.HotModuleReplacementPlugin(), 14 | new MiniCssExtractPlugin({ 15 | filename: project.styles.dist.filename.dev 16 | }) 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.conf.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | const project = require('./project.json'); 6 | 7 | module.exports = { 8 | mode: 'production', 9 | output: { 10 | filename: project.scripts.dist.filename.prod 11 | }, 12 | optimization: { 13 | minimizer: [ 14 | new TerserPlugin({ 15 | terserOptions: { 16 | mangle: true, 17 | compress: { 18 | warnings: false 19 | } 20 | } 21 | }), 22 | new OptimizeCSSAssetsPlugin() 23 | ] 24 | }, 25 | plugins: [ 26 | new MiniCssExtractPlugin({ 27 | filename: project.styles.dist.filename.prod 28 | }) 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const baseConfig = require('./webpack.conf.base'); 3 | const devConfig = require('./webpack.conf.dev'); 4 | const prodConfig = require('./webpack.conf.prod'); 5 | const specificConfig = process.env.NODE_ENV == 'production' ? prodConfig : devConfig; 6 | 7 | module.exports = merge(baseConfig, specificConfig); 8 | --------------------------------------------------------------------------------