├── src ├── favicon.ico ├── components │ ├── command-editor │ │ ├── styles.css │ │ └── index.js │ ├── app │ │ ├── index.js │ │ └── styles.css │ ├── controls │ │ ├── styles.css │ │ └── index.js │ └── terminal │ │ ├── terminal.css │ │ └── index.js ├── demos │ ├── index.js │ ├── feature-tests.js │ ├── create-react-app.js │ ├── sammie.js │ └── create-angular-app.js ├── index.html ├── index.js └── utils │ └── exporting.js ├── .postcssrc.js ├── package.json ├── .gitignore ├── DOCS.md ├── README.md └── example.svg /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neatsoftware/term-sheets/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/components/command-editor/styles.css: -------------------------------------------------------------------------------- 1 | .commandEditor { 2 | width: 100%; 3 | max-width: 780px; 4 | min-height: 550px; 5 | } 6 | 7 | .code { 8 | margin: 10px 0; 9 | } 10 | -------------------------------------------------------------------------------- /src/demos/index.js: -------------------------------------------------------------------------------- 1 | export { default as createReactApp } from './create-react-app' 2 | export { default as sammie } from './sammie' 3 | export { default as featureTests } from './feature-tests' 4 | export { default as createAngularApp } from './create-angular-app' 5 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | modules: true, 3 | plugins: { 4 | autoprefixer: true, 5 | 'postcss-modules': { 6 | generateScopedName: process.env.NODE_ENV === 'production' ? '[hash:base64:3]' : '[local]__[hash:base64:3]' 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/app/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'hyperapp' 2 | import { terminalView } from '../terminal' 3 | import { controlsView } from '../controls' 4 | import styles from './styles.css' 5 | 6 | export const appView = (props) => 7 | h('div', { class: styles.app }, [ 8 | h('div', { class: styles.exportContainer, oncreate: props.updateTerminalEl }, [h(terminalView, props)]), 9 | h(controlsView, props) 10 | ]) 11 | -------------------------------------------------------------------------------- /src/components/controls/styles.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | margin: 20px auto; 3 | } 4 | 5 | .control { 6 | display: flex; 7 | align-items: center; 8 | margin: 7px 0; 9 | } 10 | 11 | .controlLabel, 12 | .controlLabelSub { 13 | font-size: 12px; 14 | text-transform: uppercase; 15 | margin: 0 10px; 16 | } 17 | 18 | .controlLabel { 19 | min-width: 90px; 20 | } 21 | 22 | .exportOptLabel { 23 | padding: 0 12px 0 4px; 24 | font-size: 12px; 25 | text-transform: uppercase; 26 | } 27 | 28 | .exportActions { 29 | text-align: center; 30 | } 31 | 32 | .alpha { 33 | text-transform: none; 34 | font-size: 9px; 35 | vertical-align: text-top; 36 | margin: 0 0 0 3px; 37 | } 38 | 39 | .disabled { 40 | opacity: 0.5; 41 | pointer-events: none; 42 | } 43 | 44 | .divider { 45 | border: 0; 46 | height: 1px; 47 | background: #ccc; 48 | margin: 12px 0; 49 | } 50 | 51 | input[type='color' i]::-webkit-color-swatch-wrapper { 52 | padding: 0; 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "term-sheets", 3 | "version": "1.0.0", 4 | "description": "Create animated terminal presentations. Export as SVG, animated GIF, or HTML+CSS", 5 | "author": "Garth Poitras ", 6 | "repository": "https://github.com/neatsoftware/term-sheets", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "parcel src/index.html --no-cache", 10 | "build": "parcel build src/index.html --public-url '.' --no-source-maps --no-cache", 11 | "test": "flt", 12 | "clean": "rm -rf dist & rm -rf .*cache" 13 | }, 14 | "dependencies": { 15 | "codemirror": "^5.63.1", 16 | "gifshot": "^0.4.5", 17 | "hyperapp": "^1.2.10" 18 | }, 19 | "devDependencies": { 20 | "autoprefixer": "^9.8.7", 21 | "flt": "^0.40.0", 22 | "parcel-bundler": "^1.12.5", 23 | "postcss-modules": "^1.4.1" 24 | }, 25 | "browserslist": [ 26 | "Chrome >= 49", 27 | "FF >= 41", 28 | "Edge >= 14", 29 | "Safari >= 9" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (https://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules/ 31 | jspm_packages/ 32 | 33 | # TypeScript v1 declaration files 34 | typings/ 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional eslint cache 40 | .eslintcache 41 | 42 | # Optional REPL history 43 | .node_repl_history 44 | 45 | # Output of 'npm pack' 46 | *.tgz 47 | 48 | # Yarn Integrity file 49 | .yarn-integrity 50 | 51 | # dotenv environment variables file 52 | .env 53 | 54 | # app 55 | dist 56 | .cache 57 | deploy.sh 58 | -------------------------------------------------------------------------------- /DOCS.md: -------------------------------------------------------------------------------- 1 | ## Documentation 2 | 3 | ### Content payload API 4 | 5 | - Must be serializable json 6 | - The root must be an array containing objects with `input`, `output` properties. `input` is the typing input. `output` is the response to the input. 7 | - `input` can either be a string or an object with `content`, `prompt` properties. `content` is the string text. `prompt` is a custom prompt for that input line. 8 | - `output` must be an array of strings or objects with `content`, `replace` properties. The string or `content` is the string text (may contain html). `replace` indicates that the next line should replace the output instead of appending. 9 | 10 | ```json 11 | [ 12 | { 13 | "input": "an input string", 14 | "output": ["an output string"] 15 | }, 16 | { 17 | "input": { 18 | "prompt": "my-prompt >", 19 | "content": "an input string" 20 | }, 21 | "output": [ 22 | { 23 | "content": "please wait", 24 | "replace": true 25 | }, 26 | "done!" 27 | ] 28 | } 29 | ] 30 | ``` 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Term Sheets 2 | 3 | Create animated terminal presentations. Export as SVG, animated GIF, or HTML+CSS. 4 | 5 | ### [Use app ↗️](https://term-sheets.neat.software) 6 | 7 | --- 8 | 9 | Term sheets is a JavaScript app to produce terminal typing and output animations for embedding in presentations, readmes, tweets, etc. Other solutions usually involve recording a live screen. I wanted a way to simply provide a payload of instructions so I didn't have to rehearse my typing and wait for network output, as well as style it to look nice. 10 | 11 | ### Example: 12 | 13 |

14 | 15 |

16 | 17 | ### [Documentation](DOCS.md) 18 | 19 | ### Goals: 20 | 21 | 1. Client-side only 22 | 2. Exported term-sheets should be pure markup/CSS 23 | 3. SVG exports should remain portable/strict so they can be embedded in GitHub readmes 24 | -------------------------------------------------------------------------------- /src/components/command-editor/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'hyperapp' 2 | import CodeMirror from 'codemirror' 3 | import 'codemirror/mode/javascript/javascript.js' 4 | import 'codemirror/lib/codemirror.css' 5 | import 'codemirror/theme/dracula.css' 6 | import styles from './styles.css' 7 | 8 | let editor 9 | function setupCodeEditor(element, code) { 10 | editor = CodeMirror(element, { 11 | mode: 'javascript', 12 | theme: 'dracula', 13 | value: JSON.stringify(code, null, 2) 14 | }) 15 | } 16 | 17 | function save(props) { 18 | let updatedCode 19 | try { 20 | updatedCode = JSON.parse(editor.getValue()) 21 | } catch (e) { 22 | return alert('Invalid JSON') 23 | } 24 | props.updateCommandsAndPlay(updatedCode) 25 | props.toggleEditingCommands() 26 | } 27 | 28 | export const commandEditor = (props) => 29 | h('div', { class: styles.commandEditor }, [ 30 | h('div', { class: styles.code }, [h('div', { oncreate: (el) => setupCodeEditor(el, props.commands) })]), 31 | h('button', { onclick: () => save(props) }, 'Save'), 32 | h('button', { onclick: props.toggleEditingCommands }, 'Cancel') 33 | ]) 34 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | Term Sheets 11 | 12 | 13 | 14 | 15 |
16 |

TermSheets

17 |

Create animated terminal presentations. Export as SVG, animated GIF, or HTML+CSS

18 |
19 | 23 |
24 | 25 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/demos/feature-tests.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | input: 'simple input', 4 | output: ['simple output'] 5 | }, 6 | { 7 | input: `let's see some color`, 8 | output: [ 9 | `Wow it's cyan and yellow and red and back` 10 | ] 11 | }, 12 | { 13 | input: 'try out some unicode and emojis 😍', 14 | output: ['🔥 Nice ✔︎'] 15 | }, 16 | { 17 | input: 'spinners via "replace"', 18 | output: [ 19 | { content: '| Loading', replace: true }, 20 | { content: '/ Loading', replace: true }, 21 | { content: '- Loading', replace: true }, 22 | { content: '\\ Loading', replace: true }, 23 | { content: '| Loading', replace: true }, 24 | { content: '/ Loading', replace: true }, 25 | { content: '- Loading', replace: true }, 26 | { content: 'Complete' } 27 | ] 28 | }, 29 | { 30 | input: 'how about some spacing?', 31 | output: [' left pad', 'right pad !', 'the lines...

can break'] 32 | }, 33 | { 34 | input: 'it automatically tails content, with only css!', 35 | output: [ 36 | 'line 1', 37 | 'line 2', 38 | 'line 3', 39 | 'line 4', 40 | 'line 5', 41 | 'line 6', 42 | 'line 7', 43 | 'line 8', 44 | 'line 9', 45 | 'line 10', 46 | 'Done!' 47 | ] 48 | } 49 | ] 50 | -------------------------------------------------------------------------------- /src/demos/create-react-app.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | input: 'npx create-react-app my-app', 4 | output: [ 5 | { content: '| Installing...', replace: true }, 6 | { content: '/ Installing...', replace: true }, 7 | { content: '- Installing...', replace: true }, 8 | { content: '\\ Installing...', replace: true }, 9 | { content: '| Installing...', replace: true }, 10 | { content: '/ Installing...', replace: true }, 11 | { content: '- Installing...', replace: true }, 12 | 'npx: installed 67 in 3.500s', 13 | '
Creating a new React app in ~/my-app', 14 | '
Installing react, react-dom, and react-scripts...', 15 | { content: '| Installing...', replace: true }, 16 | { content: '/ Installing...', replace: true }, 17 | { content: '- Installing...', replace: true }, 18 | { content: '\\ Installing...', replace: true }, 19 | { content: '| Installing...', replace: true }, 20 | { content: '/ Installing...', replace: true }, 21 | { content: '- Installing...', replace: true }, 22 | '✔︎ Done

' 23 | ] 24 | }, 25 | { 26 | input: 'cd my-app' 27 | }, 28 | { 29 | input: { 30 | prompt: 'my-app$', 31 | content: 'npm start' 32 | }, 33 | output: [ 34 | '
Starting the development server...', 35 | 'Compiled successfully!', 36 | '
Your can now view my-app in the browser.
Local: http://localhost:3000/
On Your Network: http://192.168.37.106:3000/' 37 | ] 38 | } 39 | ] 40 | -------------------------------------------------------------------------------- /src/components/app/styles.css: -------------------------------------------------------------------------------- 1 | *, 2 | *:before, 3 | *:after { 4 | box-sizing: inherit; 5 | } 6 | 7 | html { 8 | box-sizing: border-box; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | color: #444; 14 | } 15 | 16 | body, 17 | input, 18 | button { 19 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 20 | } 21 | 22 | button { 23 | background: #444; 24 | color: #fff; 25 | border: 0; 26 | border-radius: 3px; 27 | padding: 4px 8px; 28 | cursor: pointer; 29 | margin: 0 4px 0 0; 30 | } 31 | 32 | button:active { 33 | background: #222; 34 | } 35 | 36 | input { 37 | padding: 4px; 38 | border-radius: 3px; 39 | border: 1px solid #ccc; 40 | max-width: 80px; 41 | } 42 | 43 | main { 44 | display: flex; 45 | flex-direction: column; 46 | align-items: center; 47 | padding: 40px 20px; 48 | } 49 | 50 | h1 { 51 | margin: 0 0 20px; 52 | font-size: 2.6rem; 53 | font-weight: 100; 54 | letter-spacing: 0.1rem; 55 | text-transform: uppercase; 56 | } 57 | 58 | h1 b { 59 | font-weight: 700; 60 | margin-right: 0.3rem; 61 | } 62 | 63 | h2 { 64 | margin: 0 0 40px; 65 | font-weight: 300; 66 | font-size: 1.2rem; 67 | letter-spacing: 0.03em; 68 | text-align: center; 69 | line-height: 1.4; 70 | opacity: 0.7; 71 | } 72 | 73 | footer { 74 | margin: 60px auto 0; 75 | } 76 | 77 | footer a { 78 | text-decoration: none; 79 | text-transform: uppercase; 80 | margin: 0 20px; 81 | color: #444; 82 | font-size: 10px; 83 | letter-spacing: 1px; 84 | } 85 | 86 | :global(#app) { 87 | max-width: 100%; 88 | } 89 | 90 | .app { 91 | display: flex; 92 | flex-direction: column; 93 | } 94 | 95 | .exportContainer { 96 | max-width: calc(100% + 40px); 97 | margin: 0 -20px; 98 | } 99 | -------------------------------------------------------------------------------- /src/demos/sammie.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | input: 'sammie init my-app', 4 | output: [ 5 | '[sammie] Getting AWS account id...
aws sts get-caller-identity', 6 | '[sammie] Account id: 1234567 ✔︎', 7 | '[sammie] Creating project...', 8 | '[sammie] Created: "my-app" - template: sam.json | code: index.js ✔︎' 9 | ] 10 | }, 11 | { 12 | input: 'sammie deploy', 13 | output: [ 14 | '[sammie] Validating template...
aws cloudformation validate-template --template-body sam.json', 15 | '[sammie] Template valid ✔︎', 16 | '[sammie] Creating s3 code bucket (if necessary)...
aws s3api create-bucket --bucket sam-uploads-1234567', 17 | '[sammie] Packaging and uploading code...
aws cloudformation package --template-file sam.json --output-template-file sam-packaged.json --s3-bucket sam-uploads-1234567 --use-json', 18 | '[sammie] Deploying stack: "my-app-development"...
aws cloudformation deploy --template-file sam-packaged.json --stack-name my-app-development --capabilities CAPABILITY_IAM --parameter-overrides environment=development', 19 | '[sammie] Deploy success ✔︎
[sammie] Live url: https://xxxxxx.execute-api.us-east-1.amazonaws.com/development' 20 | ] 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { h, app } from 'hyperapp' 2 | import { appView } from './components/app' 3 | import { exportProject } from './utils/exporting' 4 | import * as demos from './demos' 5 | 6 | const state = { 7 | // Terminal state 8 | background: true, 9 | bgColor1: '#40bfbf', 10 | bgColor2: '#4095bf', 11 | titleBar: true, 12 | windowButtons: true, 13 | title: '~/', 14 | prompt: '~$', 15 | promptColor: '#5ed7ff', 16 | width: 780, 17 | height: 440, 18 | speed: 1, 19 | commands: demos.featureTests, 20 | // Internal app state 21 | exportType: 'svg', 22 | isEditingCommands: false, 23 | isExporting: false, 24 | terminalEl: null 25 | } 26 | 27 | const actions = { 28 | updateBackground: (e) => ({ background: e.target.checked }), 29 | updatebgColor1: (e) => ({ bgColor1: e.target.value }), 30 | updatebgColor2: (e) => ({ bgColor2: e.target.value }), 31 | updateTitleBar: (e) => ({ titleBar: e.target.checked }), 32 | updateWindowButtons: (e) => ({ windowButtons: e.target.checked }), 33 | updateTitle: (e) => ({ title: e.target.value }), 34 | updatePrompt: (e) => ({ prompt: e.target.value }), 35 | updatePromptColor: (e) => ({ promptColor: e.target.value }), 36 | updateWidth: (e) => ({ width: e.target.value }), 37 | updateHeight: (e) => ({ height: e.target.value }), 38 | updateSpeed: (e) => ({ speed: e.target.value }), 39 | updateExportType: (e) => ({ exportType: e.target.value }), 40 | updateTerminalEl: (element) => ({ terminalEl: element }), 41 | toggleEditingCommands: () => (state) => ({ isEditingCommands: !state.isEditingCommands }), 42 | updateIsExporting: (isExporting) => ({ isExporting }), 43 | updateDemo: (e) => (state, actions) => actions.updateCommandsAndPlay(demos[e.target.value]), 44 | updateCommands: (commands) => ({ commands }), 45 | updateCommandsAndPlay: (commands) => (state, actions) => { 46 | actions.updateCommands(commands) 47 | actions.restart() 48 | }, 49 | restart: () => (state, actions) => { 50 | const commands = state.commands 51 | actions.updateCommands([]) 52 | setTimeout(() => actions.updateCommands(commands)) 53 | }, 54 | export: () => (state, actions) => { 55 | actions.updateIsExporting(true) 56 | return exportProject(state) 57 | .catch(alert) 58 | .then(() => actions.updateIsExporting(false)) 59 | } 60 | } 61 | 62 | const view = (state, actions) => h(appView, Object.assign(state, actions)) 63 | 64 | export default app(state, actions, view, document.getElementById('app')) 65 | -------------------------------------------------------------------------------- /src/components/terminal/terminal.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Inconsolata'); 2 | 3 | .terminalContainer { 4 | max-width: 100%; 5 | box-sizing: border-box; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | } 10 | 11 | .terminalBackground { 12 | padding: 45px; 13 | } 14 | 15 | .terminal { 16 | display: flex; 17 | flex-direction: column; 18 | width: 100%; 19 | height: 100%; 20 | overflow: hidden; 21 | border-radius: 5px; 22 | user-select: none; 23 | background: rgb(15, 20, 20); 24 | position: relative; 25 | } 26 | 27 | .terminalBackground .terminal { 28 | background: rgba(5, 20, 20, 0.94); 29 | box-shadow: 0 26px 43px 0 rgba(0, 0, 0, 0.47), 0 0 2px 0 rgba(0, 0, 0, 0.34); 30 | } 31 | 32 | .terminalTitleBar { 33 | border-radius: 5px 5px 0 0; 34 | text-overflow: ellipsis; 35 | padding: 0 80px; 36 | white-space: nowrap; 37 | overflow: hidden; 38 | height: 25px; 39 | line-height: 24px; 40 | text-align: center; 41 | color: #2e2e2e; 42 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 43 | font-size: 14px; 44 | background: linear-gradient(0deg, #cacaca, #e2e2e2); 45 | border-top: 0.5px solid #f3f3f3; 46 | border-bottom: 0.5px solid #7d7d7d; 47 | } 48 | 49 | .terminalButtons { 50 | height: 25px; 51 | } 52 | 53 | .terminalButtons:before { 54 | content: ''; 55 | position: absolute; 56 | top: 7px; 57 | left: 10px; 58 | width: 10px; 59 | height: 10px; 60 | background: #f95c5b; 61 | border-radius: 100%; 62 | box-shadow: 0 0 0 1px #da3d42, 20px 0 0 0 #fabe3b, 20px 0 0 1px #ecb03e, 40px 0 0 0 #38cd46, 40px 0 0 1px #2eae32; 63 | } 64 | 65 | .terminalContent { 66 | display: flex; 67 | flex-direction: column-reverse; /* auto scrolls content to bottom */ 68 | flex: 1; 69 | overflow: auto; 70 | font-family: 'Inconsolata', Consolas, 'Lucida Console', 'Lucida Sans Typewriter', Monaco, monospace; 71 | font-size: 14px; 72 | line-height: 1.36; 73 | letter-spacing: 0.03em; 74 | color: #ebebeb; 75 | white-space: pre-wrap; /* prevent spaces from collapsing */ 76 | } 77 | 78 | /* having an inner wrapper re-reverses parent's column-reverse */ 79 | .terminalContentInner { 80 | flex: 1; 81 | padding: 12px; 82 | } 83 | 84 | .terminalPrompt { 85 | display: inline-flex; 86 | flex-wrap: wrap; 87 | vertical-align: middle; 88 | padding: 0 9px 0 0; 89 | } 90 | 91 | .terminalInput { 92 | display: inline-flex; 93 | flex-wrap: wrap; 94 | vertical-align: middle; 95 | user-select: text; 96 | } 97 | 98 | .terminalOutput { 99 | user-select: text; 100 | } 101 | 102 | .terminalPrompt, 103 | .terminalInput, 104 | .terminalInput span, 105 | .terminalOutput { 106 | width: 0; 107 | height: 0; 108 | overflow: hidden; 109 | animation: appear 0.001ms linear forwards; 110 | } 111 | 112 | .terminalOutputReplace { 113 | animation: appear 0.001ms linear forwards, hide 0ms linear forwards; 114 | } 115 | 116 | .cursor { 117 | display: inline-block; 118 | vertical-align: middle; 119 | background: #666; 120 | animation: cursorAppear 0.001ms linear forwards, hide 0ms linear forwards; 121 | } 122 | 123 | @keyframes appear { 124 | to { 125 | width: auto; 126 | height: auto; 127 | } 128 | } 129 | 130 | @keyframes hide { 131 | to { 132 | width: 0; 133 | height: 0; 134 | } 135 | } 136 | 137 | @keyframes cursorAppear { 138 | to { 139 | width: 7px; 140 | height: 17px; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/components/terminal/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'hyperapp' 2 | import styles from './terminal.css' 3 | 4 | export const DELAY_TYPE_START = 750 5 | export const DELAY_TYPE_CHAR = 35 6 | export const DELAY_TYPE_SUBMIT = 350 7 | export const DELAY_OUTPUT_LINE = 500 8 | 9 | export const terminalView = (props) => 10 | h( 11 | 'div', 12 | { 13 | class: [styles.terminalContainer, props.background && styles.terminalBackground].filter(Boolean).join(' '), 14 | style: { 15 | width: props.width + 'px', 16 | height: props.height + 'px', 17 | backgroundImage: props.background ? `linear-gradient(${props.bgColor1}, ${props.bgColor2})` : null 18 | } 19 | }, 20 | [h('div', { class: styles.terminal }, [h(terminalTitleView, props), h(terminalContentView, props)])] 21 | ) 22 | 23 | const terminalTitleView = ({ titleBar, title, windowButtons }) => 24 | h( 25 | 'div', 26 | { class: [titleBar && styles.terminalTitleBar, windowButtons && styles.terminalButtons].filter(Boolean).join(' ') }, 27 | titleBar && title 28 | ) 29 | 30 | const terminalContentView = (props) => { 31 | const { commands, speed } = props 32 | const commandTimings = commands.reduce((timings, command, i) => { 33 | const inputTime = 34 | (DELAY_TYPE_START + DELAY_TYPE_CHAR * Array.from(command.input).length + DELAY_TYPE_SUBMIT) / speed 35 | const outputTime = command.output ? (DELAY_OUTPUT_LINE * command.output.length) / speed : 0 36 | const startDelay = timings.reduce((time, item) => time + item.inputTime + item.outputTime, 0) 37 | timings[i] = { inputTime, outputTime, startDelay } 38 | return timings 39 | }, []) 40 | 41 | return h('div', { class: styles.terminalContent }, [ 42 | h( 43 | 'div', 44 | { class: styles.terminalContentInner }, 45 | commands.map((command, i) => h(terminalCommandView, Object.assign(props, { commandIndex: i, commandTimings }))) 46 | ) 47 | ]) 48 | } 49 | 50 | const terminalCommandView = (props) => [h(terminalInputView, props), h(terminalOutputView, props)] 51 | 52 | const terminalInputView = (props) => { 53 | const { commands, commandIndex, commandTimings, prompt, promptColor, speed } = props 54 | const command = commands[commandIndex] 55 | const { startDelay, inputTime } = commandTimings[commandIndex] 56 | const cursorHideDelay = startDelay + inputTime 57 | const input = command.input 58 | const isObj = typeof input === 'object' 59 | const content = isObj ? input.content : input 60 | const linePrompt = (isObj && input.prompt) || prompt 61 | return [ 62 | h( 63 | 'div', 64 | { class: styles.terminalPrompt, style: { color: promptColor, animationDelay: `${startDelay}ms` } }, 65 | linePrompt 66 | ), 67 | h( 68 | 'div', 69 | { class: styles.terminalInput, style: { animationDelay: `${startDelay}ms` } }, 70 | Array.from(content).map((char, i) => { 71 | const charDelay = startDelay + DELAY_TYPE_START / speed + (DELAY_TYPE_CHAR * i) / speed 72 | return h('span', { style: { animationDelay: `${charDelay}ms` } }, char) 73 | }) 74 | ), 75 | h('div', { class: styles.cursor, style: { animationDelay: `${startDelay}ms,${cursorHideDelay}ms` } }) 76 | ] 77 | } 78 | 79 | const terminalOutputView = (props) => { 80 | const { commands, commandIndex, commandTimings, speed } = props 81 | const { startDelay, inputTime } = commandTimings[commandIndex] 82 | const outputLines = commands[commandIndex].output || [''] 83 | return outputLines.map((line, i) => { 84 | const isObj = typeof line === 'object' 85 | const content = isObj ? line.content : line 86 | const replace = isObj && line.replace 87 | const showDelay = startDelay + inputTime + (DELAY_OUTPUT_LINE * (i + 1)) / speed 88 | const hideDelay = replace && startDelay + inputTime + (DELAY_OUTPUT_LINE * (i + 2)) / speed 89 | return h('div', { 90 | class: [styles.terminalOutput, replace && styles.terminalOutputReplace].filter(Boolean).join(' '), 91 | style: { animationDelay: replace ? `${showDelay}ms,${hideDelay}ms` : `${showDelay}ms` }, 92 | oncreate(el) { 93 | el.innerHTML = content 94 | } 95 | }) 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /src/utils/exporting.js: -------------------------------------------------------------------------------- 1 | import { createGIF } from 'gifshot' 2 | import { DELAY_TYPE_START, DELAY_TYPE_CHAR, DELAY_TYPE_SUBMIT, DELAY_OUTPUT_LINE } from '../components/terminal' 3 | 4 | const GIF_FRAME_RATE = 200 5 | const GIF_RESTART_DELAY = 1000 6 | 7 | export function exportProject(state) { 8 | return { 9 | svg: exportSvg, 10 | html: exportHtml, 11 | gif: exportGif 12 | }[state.exportType](state) 13 | } 14 | 15 | function exportSvg({ terminalEl: element, width, height }) { 16 | return new Promise((resolve) => { 17 | const content = buildSvg(element, width, height) 18 | const blob = new Blob([content], { type: 'image/svg+xml' }) 19 | downloadBlob(blob, 'svg') 20 | resolve() 21 | }) 22 | } 23 | 24 | function exportHtml({ terminalEl: element }) { 25 | return new Promise((resolve) => { 26 | const css = buildCss() 27 | const markup = element.innerHTML 28 | const content = `${markup}` 29 | const blob = new Blob([content], { type: 'text/html' }) 30 | downloadBlob(blob, 'html') 31 | resolve() 32 | }) 33 | } 34 | 35 | function exportGif({ terminalEl: element, width, height, commands, speed }) { 36 | return new Promise((resolve, reject) => { 37 | const totalTime = commands.reduce((time, command) => { 38 | const inputTime = 39 | (DELAY_TYPE_START + DELAY_TYPE_CHAR * Array.from(command.input).length + DELAY_TYPE_SUBMIT) / speed 40 | const outputTime = command.output ? (DELAY_OUTPUT_LINE * command.output.length) / speed : 0 41 | return time + inputTime + outputTime 42 | }, GIF_RESTART_DELAY) 43 | const numFrames = totalTime / GIF_FRAME_RATE 44 | 45 | const img = new Image() 46 | img.onload = (e) => 47 | buildGifFrames(e.target, width, height, numFrames).then((frameImages) => { 48 | createGIF( 49 | { 50 | numFrames, 51 | images: frameImages, 52 | sampleInterval: 20, 53 | numWorkers: 4, 54 | gifWidth: width, 55 | gifHeight: height 56 | }, 57 | (result) => { 58 | const { error, image: gifBase64 } = result 59 | if (error) reject(error) 60 | resolve(downloadUrl(gifBase64, 'gif')) 61 | } 62 | ) 63 | }) 64 | img.src = `data:image/svg+xml,${encodeURIComponent(buildSvg(element, width, height))}` 65 | }) 66 | } 67 | 68 | function buildGifFrames(img, width, height, numFrames) { 69 | return Promise.all( 70 | Array.apply(null, Array(Math.ceil(numFrames))).map( 71 | (_, i) => 72 | new Promise((resolve) => { 73 | setTimeout(() => { 74 | const canvas = document.createElement('canvas') 75 | canvas.width = width 76 | canvas.height = height 77 | canvas.getContext('2d').drawImage(img, 0, 0) 78 | resolve(canvas.toDataURL()) 79 | }, i * GIF_FRAME_RATE) 80 | }) 81 | ) 82 | ) 83 | } 84 | 85 | function buildSvg(element, width, height) { 86 | const css = buildCss() 87 | const markup = new XMLSerializer().serializeToString(element) 88 | return `${markup}` 89 | } 90 | 91 | function buildCss() { 92 | return [].slice 93 | .call(document.getElementById('terminalCss').sheet.cssRules) 94 | .reduce((string, rule) => string + rule.cssText, '') 95 | .replace(/\n/g, '') 96 | } 97 | 98 | function downloadUrl(url, ext) { 99 | const a = document.createElement('a') 100 | a.href = url 101 | a.download = `term-sheet-${Date.now()}.${ext}` 102 | document.body.appendChild(a) 103 | a.click() 104 | document.body.removeChild(a) 105 | } 106 | 107 | function downloadBlob(blob, ext) { 108 | downloadUrl(URL.createObjectURL(blob), ext) 109 | URL.revokeObjectURL(blob) 110 | } 111 | -------------------------------------------------------------------------------- /src/demos/create-angular-app.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | input: 'ng new my-app', 4 | output: [ 5 | 'CREATE my-app/README.md (1027 bytes)
CREATE my-app/angular.json (3593 bytes)
CREATE my-app/package.json (1316 bytes)
CREATE my-app/tsconfig.json (408 bytes)
CREATE my-app/tslint.json (2805 bytes)
CREATE my-app/.editorconfig (245 bytes)
CREATE my-app/.gitignore (503 bytes)
CREATE my-app/src/favicon.ico (5430 bytes)
CREATE my-app/src/index.html (297 bytes)
CREATE my-app/src/main.ts (370 bytes)
CREATE my-app/src/polyfills.ts (3194 bytes)
CREATE my-app/src/test.ts (642 bytes)
CREATE my-app/src/styles.css (80 bytes)
CREATE my-app/src/browserslist (388 bytes)
CREATE my-app/src/karma.conf.js (964 bytes)
CREATE my-app/src/tsconfig.app.json (166 bytes)
CREATE my-app/src/tsconfig.spec.json (256 bytes)
CREATE my-app/src/tslint.json (314 bytes)
CREATE my-app/src/assets/.gitkeep (0 bytes)
CREATE my-app/src/environments/environment.prod.ts (51 bytes)
CREATE my-app/src/environments/environment.ts (642 bytes)
CREATE my-app/src/app/app.module.ts (314 bytes)
CREATE my-app/src/app/app.component.css (0 bytes)
CREATE my-app/src/app/app.component.html (1141 bytes)
CREATE my-app/src/app/app.component.spec.ts (1010 bytes)
CREATE my-app/src/app/app.component.ts (215 bytes)
CREATE my-app/e2e/protractor.conf.js (752 bytes)
CREATE my-app/e2e/tsconfig.e2e.json (213 bytes)
CREATE my-app/e2e/src/app.e2e-spec.ts (307 bytes)
CREATE my-app/e2e/src/app.po.ts (208 bytes)', 6 | { content: '⠟ fetchMetadata:', replace: true }, 7 | { content: '⠯ fetchMetadata:', replace: true }, 8 | { content: '⠷ fetchMetadata:', replace: true }, 9 | { content: '⠾ fetchMetadata:', replace: true }, 10 | { content: '⠽ fetchMetadata:', replace: true }, 11 | { content: '⠻ fetchMetadata:', replace: true }, 12 | { content: '⠟ fetchMetadata:', replace: true }, 13 | { content: '⠯ fetchMetadata:', replace: true }, 14 | { content: '⠷ fetchMetadata:', replace: true }, 15 | { content: '⠾ fetchMetadata:', replace: true }, 16 | { content: '⠽ fetchMetadata:', replace: true }, 17 | { content: '⠻ fetchMetadata:', replace: true }, 18 | 'added 1165 packages in 181.908s' 19 | ] 20 | }, 21 | { 22 | input: 'cd my-app' 23 | }, 24 | { 25 | input: { 26 | prompt: 'my-app$', 27 | content: 'ng serve --open' 28 | }, 29 | output: [ 30 | '** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
', 31 | { content: '10% building modules 3/3 module', replace: true }, 32 | { content: '10% building modules 3/3 module', replace: true }, 33 | { content: '10% building modules 3/3 module', replace: true }, 34 | { content: '11% building modules 14/14 module', replace: true }, 35 | { content: '16% building modules 55/56 module', replace: true }, 36 | { content: '27% building modules 143/143 module', replace: true }, 37 | { content: '35% building modules 216/216 module', replace: true }, 38 | { content: '42% building modules 272/272 module', replace: true }, 39 | { content: '90% chunk assets processing', replace: true }, 40 | { content: '92% after chunk asset optimization', replace: true }, 41 | '
Date: 2018-01-11T20:15:32.682Z
Hash: 6556fbe0a883fdc1f9b
Time: 5392ms
chunk {main} main.js, main.js.map (main) 10.7 kB [initial] [rendered]
chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 227 kB [initial] [rendered]
chunk {runtime} runtime.js, runtime.js.map (runtime) 5.22 kB [entry] [rendered]
chunk {styles} styles.js, styles.js.map (styles) 15.6 kB [initial] [rendered]
chunk {vendor} vendor.js, vendor.js.map (vendor) 3.28 MB [initial] [rendered]
「wdm」: Compiled successfully.' 42 | ] 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /src/components/controls/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'hyperapp' 2 | import { commandEditor } from '../command-editor' 3 | import * as demos from '../../demos' 4 | import styles from './styles.css' 5 | 6 | const numbersOnly = (e) => { 7 | if (e.which < 48 || e.which > 57) e.preventDefault() 8 | } 9 | 10 | export const controlsView = (props) => 11 | props.isEditingCommands 12 | ? h(commandEditor, props) 13 | : h('div', { class: styles.controls }, [ 14 | h('div', { class: styles.control }, [ 15 | h('label', { class: styles.controlLabel, for: 'backgroundCheckbox' }, 'Background:'), 16 | h('input', { 17 | type: 'checkbox', 18 | checked: props.background, 19 | onchange: props.updateBackground, 20 | id: 'backgroundCheckbox' 21 | }), 22 | h('input', { 23 | type: 'color', 24 | placeholder: 'Top color', 25 | title: 'Top background color', 26 | value: props.bgColor1, 27 | oninput: props.updatebgColor1, 28 | disabled: !props.background 29 | }), 30 | h('input', { 31 | type: 'color', 32 | placeholder: 'Bottom color', 33 | title: 'Bottom background color', 34 | value: props.bgColor2, 35 | oninput: props.updatebgColor2, 36 | disabled: !props.background 37 | }) 38 | ]), 39 | h('div', { class: styles.control }, [ 40 | h('label', { class: styles.controlLabel, for: 'titlebarCheckbox' }, 'Title bar:'), 41 | h('input', { 42 | type: 'checkbox', 43 | checked: props.titleBar, 44 | onchange: props.updateTitleBar, 45 | id: 'titlebarCheckbox' 46 | }), 47 | h('input', { 48 | placeholder: 'Title', 49 | title: 'Title bar text', 50 | value: props.title, 51 | oninput: props.updateTitle, 52 | disabled: !props.titleBar 53 | }), 54 | h('label', { class: styles.controlLabelSub, for: 'buttonsCheckbox' }, 'Buttons:'), 55 | h('input', { 56 | type: 'checkbox', 57 | checked: props.windowButtons, 58 | onchange: props.updateWindowButtons, 59 | id: 'buttonsCheckbox' 60 | }) 61 | ]), 62 | h('div', { class: styles.control }, [ 63 | h('label', { class: styles.controlLabel, for: 'promptInput' }, 'Prompt:'), 64 | h('input', { placeholder: 'Prompt', value: props.prompt, oninput: props.updatePrompt, id: 'promptInput' }), 65 | h('input', { 66 | type: 'color', 67 | placeholder: 'Prompt color', 68 | title: 'Prompt color', 69 | value: props.promptColor, 70 | oninput: props.updatePromptColor 71 | }) 72 | ]), 73 | h('div', { class: styles.control }, [ 74 | h('label', { class: styles.controlLabel, for: 'widthInput' }, 'Width:'), 75 | h('input', { 76 | placeholder: 'Width', 77 | type: 'number', 78 | min: 1, 79 | value: props.width, 80 | oninput: props.updateWidth, 81 | onkeypress: numbersOnly, 82 | id: 'widthInput' 83 | }) 84 | ]), 85 | h('div', { class: styles.control }, [ 86 | h('label', { class: styles.controlLabel, for: 'heightInput' }, 'Height:'), 87 | h('input', { 88 | placeholder: 'Height', 89 | type: 'number', 90 | min: 1, 91 | value: props.height, 92 | oninput: props.updateHeight, 93 | onkeypress: numbersOnly, 94 | id: 'heightInput' 95 | }) 96 | ]), 97 | h('div', { class: styles.control }, [ 98 | h('label', { class: styles.controlLabel, for: 'speedInput' }, 'Speed:'), 99 | h('input', { 100 | placeholder: 'Speed', 101 | type: 'number', 102 | min: 1, 103 | value: props.speed, 104 | oninput: props.updateSpeed, 105 | onkeypress: numbersOnly, 106 | id: 'speedInput' 107 | }) 108 | ]), 109 | h('div', { class: styles.control }, [ 110 | h('label', { class: styles.controlLabel }, 'Content:'), 111 | h('button', { onclick: props.toggleEditingCommands }, 'Edit...'), 112 | h('button', { onclick: props.restart }, 'Restart'), 113 | h('select', { onchange: props.updateDemo, title: 'Demos' }, [ 114 | h('option', { disabled: true, selected: true, hidden: true, value: '' }, 'Or select a demo'), 115 | ...Object.keys(demos).map((name) => 116 | h('option', { value: name }, name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()) 117 | ) 118 | ]) 119 | ]), 120 | h('hr', { class: styles.divider }), 121 | h('div', { class: [styles.control, props.isExporting && styles.disabled].filter(Boolean).join(' ') }, [ 122 | h('label', { class: styles.controlLabel }, 'Export as:'), 123 | h('input', { 124 | type: 'radio', 125 | value: 'svg', 126 | checked: props.exportType === 'svg', 127 | id: 'exportTypeSvg', 128 | name: 'exportType', 129 | onchange: props.updateExportType 130 | }), 131 | h('label', { for: 'exportTypeSvg', class: styles.exportOptLabel }, 'SVG'), 132 | h('input', { 133 | type: 'radio', 134 | value: 'html', 135 | checked: props.exportType === 'html', 136 | id: 'exportTypeHtml', 137 | name: 'exportType', 138 | onchange: props.updateExportType 139 | }), 140 | h('label', { for: 'exportTypeHtml', class: styles.exportOptLabel }, 'HTML'), 141 | h('input', { 142 | type: 'radio', 143 | value: 'gif', 144 | checked: props.exportType === 'gif', 145 | id: 'exportTypeGif', 146 | name: 'exportType', 147 | onchange: props.updateExportType 148 | }), 149 | h('label', { for: 'exportTypeGif', class: styles.exportOptLabel }, [ 150 | 'GIF', 151 | h('span', { class: styles.alpha }, '(alpha)') 152 | ]), 153 | h('div', { class: styles.exportActions }, [ 154 | h('button', { onclick: props.export }, props.isExporting ? 'Exporting...' : 'Export') 155 | ]) 156 | ]) 157 | ]) 158 | -------------------------------------------------------------------------------- /example.svg: -------------------------------------------------------------------------------- 1 |
~/term-sheets
simple input
simple output
let's see some color
Wow it's cyan and yellow and red and back
try out some unicode and emojis 😍
🔥 Nice ✔︎
spinners via "replace"
| Loading
/ Loading
- Loading
\ Loading
| Loading
/ Loading
- Loading
Complete
how about some spacing?
left pad
right pad !
the lines...

can break
it automatically tails content, with only css!
line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9
line 10
Done!
--------------------------------------------------------------------------------