├── .gitignore ├── CHANGELOG.md ├── README.md ├── lerna.json ├── package-lock.json ├── package.json └── packages ├── caspar-graphics ├── bin │ └── caspar-graphics.js ├── package.json ├── src │ ├── client │ │ ├── Checkbox.jsx │ │ ├── Input.jsx │ │ ├── JsonEditor.jsx │ │ ├── Menu.jsx │ │ ├── Popover.jsx │ │ ├── Screen.jsx │ │ ├── Settings.jsx │ │ ├── Sidebar.jsx │ │ ├── Switch.jsx │ │ ├── TemplatePreview.jsx │ │ ├── app.jsx │ │ ├── checkbox.module.css │ │ ├── favicon.png │ │ ├── global.css │ │ ├── index.html │ │ ├── index.jsx │ │ ├── index.module.css │ │ ├── input.module.css │ │ ├── menu.module.css │ │ ├── screen.module.css │ │ ├── settings.module.css │ │ ├── sidebar.module.css │ │ ├── switch.module.css │ │ └── use-persistent-value.js │ ├── index.js │ └── node │ │ ├── build.js │ │ ├── cli.js │ │ ├── paths.js │ │ ├── server.js │ │ └── utils │ │ └── gzip.js ├── vite.config.js └── vite.config.lib.js ├── create-caspar-graphics ├── index.js ├── package.json ├── template-react-framer-motion │ ├── _gitignore │ ├── package.json │ └── templates │ │ └── example │ │ ├── index.html │ │ ├── index.jsx │ │ ├── manifest.json │ │ └── style.css ├── template-react-gsap │ ├── _gitignore │ ├── package.json │ └── templates │ │ └── example │ │ ├── index.html │ │ ├── index.jsx │ │ ├── manifest.json │ │ └── style.css ├── template-react │ ├── _gitignore │ ├── package.json │ └── templates │ │ └── example │ │ ├── index.html │ │ ├── index.jsx │ │ └── manifest.json └── template-vanilla │ ├── _gitignore │ ├── package.json │ └── templates │ └── example │ ├── index.html │ └── manifest.json ├── examples ├── README.md ├── package.json └── templates │ ├── custom-font │ ├── Exo-VariableFont_wght.ttf │ ├── RobotoCondensed-Bold.ttf │ ├── RobotoCondensed-Regular.ttf │ ├── index.html │ ├── index.jsx │ ├── manifest.json │ └── style.css │ ├── fit-text │ ├── index.html │ ├── index.jsx │ ├── manifest.json │ └── style.css │ ├── framer-motion │ ├── index.html │ ├── index.jsx │ ├── manifest.json │ └── style.css │ ├── gsap │ ├── index.html │ ├── index.jsx │ ├── manifest.json │ └── style.css │ ├── responsive-width │ ├── index.html │ ├── index.jsx │ ├── manifest.json │ └── style.css │ ├── split-text-gsap │ ├── index.html │ ├── index.jsx │ ├── manifest.json │ └── style.css │ ├── vanilla-gsap │ ├── index.html │ └── manifest.json │ └── webscreen │ ├── index.html │ ├── index.jsx │ ├── manifest.json │ └── style.css ├── graphics-kit ├── package.json ├── rollup.config.js └── src │ ├── Crawl.jsx │ ├── FitText.jsx │ ├── FramerMotion.jsx │ ├── Gsap.jsx │ ├── Image.jsx │ ├── SplitText.jsx │ ├── TemplateProvider.jsx │ ├── constants.js │ ├── index.js │ ├── render.js │ ├── use-animated-change.js │ ├── use-caspar.js │ ├── use-clock.js │ ├── use-fetch.js │ ├── use-font.js │ ├── use-interval.js │ ├── use-previous.js │ ├── use-rss-feed.js │ ├── use-timeout.js │ └── utils │ └── parse.js └── www ├── components ├── app.jsx └── app.module.css ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _meta.json ├── about.mdx ├── docs │ ├── _meta.json │ ├── build-for-caspar.mdx │ ├── components │ │ ├── _meta.json │ │ ├── crawl.mdx │ │ ├── framer-motion.mdx │ │ └── gsap-timeline.mdx │ ├── create-your-first-graphic.mdx │ ├── create-your-first-graphic │ │ ├── _meta.json │ │ ├── create-a-project.mdx │ │ ├── in-out-animations.mdx │ │ ├── introducing-state.mdx │ │ └── sending-data.mdx │ ├── getting-started.mdx │ ├── graphics-kit.mdx │ ├── hooks │ │ ├── _meta.json │ │ ├── use-caspar-data.mdx │ │ ├── use-caspar-state.mdx │ │ ├── use-caspar.mdx │ │ ├── use-fetch.mdx │ │ ├── use-font.mdx │ │ └── use-image.mdx │ ├── index.mdx │ ├── manifest.mdx │ ├── preview-images.mdx │ ├── preview-presets.mdx │ ├── responsive.mdx │ └── states.mdx ├── examples.mdx ├── index.mdx └── index.module.css ├── public ├── aspect-ratios.jpg ├── example.jpg ├── favicon.png ├── in-out-animations.mp4 ├── initial-launch.jpg ├── lowerthird-1.jpg ├── lowerthird-data-text.jpg ├── lowerthird-hello-world.jpg ├── lowerthird-off.jpg ├── preview-image.jpg ├── react-example-1.jpg └── thierry-henry.jpg └── theme.config.tsx /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | .DS_Store 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Dependency directory 25 | node_modules 26 | 27 | # Optional npm cache directory 28 | .npm 29 | 30 | # Optional REPL history 31 | .node_repl_history 32 | 33 | # Build Output 34 | build 35 | dist 36 | .vscode 37 | .vercel 38 | .next 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v3.0.0 4 | 5 | Added better support for building responsive graphics. See https://gfx.nxtedition.com/docs/responsive for more information. 6 | 7 | **Breaking:** 8 | 9 | All fixed size graphics need to specify their width and height in manifest.json. E.g. if your existing graphics only support being played in 1920x1080 pixels, you should add this to each graphic's manifest.json file: 10 | 11 | ```json 12 | { 13 | "width": 1920, 14 | "height": 1080 15 | } 16 | ``` 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Caspar Graphics 2 | 3 | ![The UI for Caspar Graphics](/packages/www/public/example.jpg) 4 | 5 | Caspar Graphics is a tool for building, testing and packaging HTML graphics for CasparCG using code. We created Caspar Graphics to make it easier for ourselves to build and maintain graphic packages. It offers a number of key benefits for people looking to create graphics efficiently and effectively: 6 | 7 | - A simple and intuitive UI makes it easy to interact with your graphics — send data updates, play and stop animations, and view all of your graphics together to make sure they're positioned properly. 8 | - No configuration is needed with out-of-the-box support for TypeScript, JSX, CSS and more. 9 | - Lightning Fast HMR (Hot Module Replacement) enables you to quickly see your changes as they're made. 10 | - Connect to a CasparCG server to easily test your graphics in a production environment. 11 | - Package your graphics into an optimized bundle, ready to be played in CasparCG. 12 | 13 | We've built Caspar Graphics on top of [Vite](https://vitejs.dev/), which offers fast and reliable performance with no need for configuration. This means that you can benefit from most of those features as well. 14 | 15 | [Read the Docs to Learn More.](https://gfx.nxtedition.com) 16 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "independent", 3 | "$schema": "node_modules/lerna/schemas/lerna-schema.json" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*" 5 | ], 6 | "bugs": "https://github.com/nxtedition/create-caspar-graphics/issues", 7 | "homepage": "https://github.com/nxtedition/create-caspar-graphics#readme", 8 | "devDependencies": { 9 | "husky": "2.1.0", 10 | "lerna": "8.0.1", 11 | "lint-staged": "^4.0.0", 12 | "prettier": "^3.1.1" 13 | }, 14 | "scripts": { 15 | "format": "prettier --single-quote --no-semi --write 'packages/*/*.js' 'packages/*/!(node_modules)/**/*.js'", 16 | "examples": "lerna run examples", 17 | "dev": "lerna run --parallel dev" 18 | }, 19 | "lint-staged": { 20 | "*.js": [ 21 | "prettier --single-quote --no-semi --write", 22 | "git add" 23 | ] 24 | }, 25 | "prettier": { 26 | "tabWidth": 2, 27 | "semi": false, 28 | "singleQuote": true 29 | }, 30 | "husky": { 31 | "hooks": { 32 | "pre-commit": "lint-staged" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/caspar-graphics/bin/caspar-graphics.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import('../src/node/cli.js') 4 | -------------------------------------------------------------------------------- /packages/caspar-graphics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nxtedition/caspar-graphics", 3 | "version": "3.0.3", 4 | "type": "module", 5 | "description": "A UI for building graphics for CasparCG in isolation.", 6 | "repository": "github:nxtedition/create-caspar-graphics", 7 | "author": "nxtedition", 8 | "license": "MIT", 9 | "bin": { 10 | "caspar-graphics": "./bin/caspar-graphics.js" 11 | }, 12 | "main": "./dist/caspar-graphics.umd.cjs", 13 | "module": "./dist/caspar-graphics.js", 14 | "exports": { 15 | ".": { 16 | "import": "./dist/caspar-graphics.js", 17 | "require": "./dist/caspar-graphics.umd.cjs" 18 | }, 19 | "./dist/*.css": { 20 | "import": "./dist/*.css", 21 | "require": "./dist/*.css" 22 | } 23 | }, 24 | "scripts": { 25 | "dev": "vite dev", 26 | "build": "vite build && vite build -c vite.config.lib.js", 27 | "prepublishOnly": "vite build && vite build -c vite.config.lib.js", 28 | "preview": "vite preview" 29 | }, 30 | "files": [ 31 | "bin", 32 | "src", 33 | "dist" 34 | ], 35 | "keywords": [ 36 | "CasparCG", 37 | "graphics", 38 | "HTML graphics" 39 | ], 40 | "prettier": { 41 | "tabWidth": 2, 42 | "semi": false, 43 | "singleQuote": true 44 | }, 45 | "dependencies": { 46 | "@monaco-editor/react": "^4.6.0", 47 | "@radix-ui/colors": "^0.1.8", 48 | "@radix-ui/react-checkbox": "^1.0.1", 49 | "@radix-ui/react-collapsible": "^1.0.1", 50 | "@radix-ui/react-dropdown-menu": "^2.0.1", 51 | "@radix-ui/react-popover": "^1.0.2", 52 | "@radix-ui/react-switch": "^1.0.1", 53 | "@radix-ui/react-tabs": "^1.0.1", 54 | "@vitejs/plugin-react": "4.4.1", 55 | "@vitejs/plugin-vue": "5.2.3", 56 | "cac": "^6.7.14", 57 | "chalk": "5.1.2", 58 | "chokidar": "^3.5.3", 59 | "framer-motion": "^7.6.9", 60 | "get-port": "^6.1.2", 61 | "json5": "^2.2.1", 62 | "monaco-editor": "^0.45.0", 63 | "prop-types": "^15.8.1", 64 | "react": "^18.2.0", 65 | "react-colorful": "^5.6.1", 66 | "react-dom": "^18.2.0", 67 | "react-icons": "^4.6.0", 68 | "vite": "5.0.10", 69 | "vite-plugin-singlefile": "^0.13.5", 70 | "ws": "^8.11.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/caspar-graphics/src/client/Checkbox.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as Primitive from '@radix-ui/react-checkbox' 3 | import styles from './checkbox.module.css' 4 | import { MdCheck } from 'react-icons/md' 5 | 6 | export const Checkbox = ({ value, onChange, label, id }) => { 7 | return ( 8 |
9 | 15 | 16 | 17 | 18 | 19 | {label != null && ( 20 | 21 | 24 | )} 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /packages/caspar-graphics/src/client/Input.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './input.module.css' 3 | 4 | export const Input = ({ type = 'text', id, label, onChange, ...props }) => { 5 | return ( 6 |
7 | 8 | { 13 | onChange(evt.target.value) 14 | }} 15 | {...props} 16 | /> 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /packages/caspar-graphics/src/client/JsonEditor.jsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useRef, useState, useImperativeHandle } from 'react' 2 | import Editor from '@monaco-editor/react' 3 | import JSON5 from 'json5' 4 | 5 | export const JsonEditor = forwardRef(function JsonEditor( 6 | { value, onChange }, 7 | forwardedRef 8 | ) { 9 | const ref = useRef() 10 | const [editor, setEditor] = useState() 11 | const stringified = JSON.stringify(value, null, 2) 12 | 13 | useImperativeHandle( 14 | forwardedRef, 15 | () => ({ 16 | getValue: () => { 17 | try { 18 | editor.updateOptions({ renderValidationDecorations: 'off' }) 19 | return JSON5.parse(editor.getValue()) 20 | } catch (err) { 21 | console.warn('Invalid JSON', err) 22 | return null 23 | } 24 | } 25 | }), 26 | [editor] 27 | ) 28 | 29 | return ( 30 |
37 | { 67 | const theme = { 68 | base: 'vs-dark', 69 | inherit: false, 70 | rules: [ 71 | { token: '', foreground: 'fafafa', background: '151718' }, 72 | { token: 'number', foreground: 'fd9d63', fontStyle: 'bold' }, 73 | { token: 'delimiter', foreground: 'fafafa' }, 74 | { token: 'string.key.json', foreground: '73daca' }, 75 | { token: 'string.value.json', foreground: '96c466' }, 76 | { token: 'string', foreground: 'fafafa' }, 77 | { token: 'keyword.json', foreground: 'fd9d63', fontStyle: 'bold' } 78 | ], 79 | colors: { 80 | 'editor.background': '#151718', 81 | 'editor.foreground': '#fafafa' 82 | } 83 | } 84 | monaco.editor.defineTheme('custom', theme) 85 | }} 86 | onMount={editor => { 87 | setEditor(editor) 88 | }} 89 | onChange={(value) => { 90 | try { 91 | onChange(JSON5.parse(value)) 92 | } catch (err) { 93 | // TODO: show that the state is invalid 94 | } 95 | }} 96 | /> 97 |
98 | ) 99 | }) 100 | -------------------------------------------------------------------------------- /packages/caspar-graphics/src/client/Menu.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as MenuPrimitive from '@radix-ui/react-dropdown-menu' 3 | import styles from './menu.module.css' 4 | import { MdChevronRight } from 'react-icons/md' 5 | 6 | export const Menu = MenuPrimitive.Root 7 | export const MenuTrigger = MenuPrimitive.Trigger 8 | 9 | export const MenuContent = ({ children, ...props }) => { 10 | return ( 11 | 12 | {children} 13 | 14 | 15 | ) 16 | } 17 | 18 | export const MenuItem = ({ children, ...props }) => { 19 | return ( 20 | 21 | {children} 22 | 23 | ) 24 | } 25 | 26 | export const MenuRadioGroup = ({ children, ...props }) => { 27 | return ( 28 | 29 | {children} 30 | 31 | ) 32 | } 33 | 34 | export const MenuRadioItem = ({ children, ...props }) => { 35 | return ( 36 | 37 | 38 | 45 | 49 | 50 | 51 | {children} 52 | 53 | ) 54 | } 55 | 56 | export const MenuSub = MenuPrimitive.Sub 57 | 58 | export const MenuSubContent = ({ children, ...props }) => { 59 | return ( 60 | 61 | {children} 62 | 63 | ) 64 | } 65 | 66 | export const MenuSubTrigger = ({ children }) => { 67 | return ( 68 | 69 | {children} 70 |
71 | 72 |
73 |
74 | ) 75 | } 76 | 77 | export const MenuLabel = ({ children }) => { 78 | return ( 79 | 80 | {children} 81 | 82 | ) 83 | } 84 | 85 | export const MenuSeparator = () => { 86 | return 87 | } 88 | 89 | -------------------------------------------------------------------------------- /packages/caspar-graphics/src/client/Popover.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as PopoverPrimitive from '@radix-ui/react-popover' 3 | 4 | export const Popover = PopoverPrimitive.Root 5 | export const PopoverTrigger = PopoverPrimitive.Trigger 6 | export const PopoverContent = props => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /packages/caspar-graphics/src/client/Screen.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useSize } from '@radix-ui/react-use-size' 3 | import styles from './screen.module.css' 4 | 5 | export const Screen = ({ children, background, image, aspectRatio }) => { 6 | const [ref, setRef] = useState() 7 | const containerSize = useSize(ref) 8 | 9 | let width = containerSize?.width 10 | let height = containerSize?.height 11 | 12 | if (aspectRatio && containerSize) { 13 | const containerAspectRatio = containerSize.width / containerSize.height 14 | const [rWidth, rHeight] = aspectRatio.split(':').map(Number) 15 | const ratio = rWidth / rHeight 16 | 17 | if (ratio > containerAspectRatio) { 18 | width = containerSize.width 19 | height = containerSize.width / ratio 20 | } else { 21 | width = containerSize.height * ratio 22 | height = containerSize.height 23 | } 24 | } 25 | 26 | return ( 27 |
28 |
36 | {containerSize ? children({ width, height }) : null} 37 | {image ? ( 38 | 43 | ) : null} 44 |
45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /packages/caspar-graphics/src/client/Settings.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import styles from './settings.module.css' 3 | import { MdMenu, MdMoreHoriz, MdMoreVert, MdSettings } from 'react-icons/md' 4 | import { FiSun, FiMoon, FiChevronsLeft } from 'react-icons/fi' 5 | import { IoIosSettings } from 'react-icons/io' 6 | import { Switch } from './Switch' 7 | import { HexColorPicker } from 'react-colorful' 8 | import { Popover, PopoverTrigger, PopoverContent } from './Popover' 9 | import { Input } from './Input' 10 | import { 11 | Menu, 12 | MenuTrigger, 13 | MenuContent, 14 | MenuRadioGroup, 15 | MenuRadioItem 16 | } from './Menu' 17 | import { ASPECT_RATIOS } from './app' 18 | 19 | export const TopSettings = ({ value, onChange }) => { 20 | const { 21 | showSidebar = true, 22 | autoPlay, 23 | showJson, 24 | serverUrl, 25 | serverChannel, 26 | } = value 27 | 28 | if (!showSidebar) { 29 | return ( 30 |
31 | 40 |
41 | ) 42 | } 43 | 44 | return ( 45 |
46 | 47 | 48 | 49 | 50 | 55 |

CasparCG Server

56 | { 60 | onChange((value) => ({ ...value, serverUrl })) 61 | }} 62 | /> 63 | { 69 | onChange((value) => ({ ...value, serverChannel })) 70 | }} 71 | /> 72 |
73 |
74 |
75 | 76 | { 82 | onChange((value) => ({ ...value, autoPlay })) 83 | }} 84 | /> 85 |
86 |
87 | 88 | { 94 | onChange((value) => ({ ...value, showJson })) 95 | }} 96 | /> 97 |
98 | 106 |
107 | ) 108 | } 109 | 110 | export const BottomSettings = ({ 111 | settings, 112 | onSettingsChange, 113 | projectState, 114 | onProjectStateChange, 115 | colorMode, 116 | }) => { 117 | const { colorScheme } = settings 118 | const { background, aspectRatio } = projectState || {} 119 | 120 | return ( 121 |
122 |
123 | 124 | 125 | 126 | 127 | 128 | 129 | { 132 | onProjectStateChange((value) => ({ ...value, background })) 133 | }} 134 | /> 135 | 136 | 137 |
138 | 139 | 140 | {colorMode === 'light' ? : } 141 | 142 | 143 | { 146 | onSettingsChange((value) => ({ ...value, colorScheme })) 147 | }} 148 | > 149 | System 150 | Light 151 | Dark 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | { 163 | onProjectStateChange((value) => ({ ...value, aspectRatio })) 164 | }} 165 | > 166 | {Object.entries(ASPECT_RATIOS).map(([label, value]) => ( 167 | {label} 168 | ))} 169 | Off 170 | 171 | 172 | 173 |
174 | ) 175 | } 176 | 177 | const GuideIcon = () => { 178 | return ( 179 | 186 | 192 | 200 | 208 | 217 | 226 | 227 | ) 228 | } 229 | -------------------------------------------------------------------------------- /packages/caspar-graphics/src/client/Switch.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as SwitchPrimitive from '@radix-ui/react-switch' 3 | import styles from './switch.module.css' 4 | import { motion } from 'framer-motion' 5 | 6 | export const Switch = ({ checked, onChange, className, labels, id, ...props }) => { 7 | return ( 8 | 16 | 17 | 23 | 24 | {labels !== false && ( 25 | <> 26 |
27 | Off 28 |
29 |
30 | On 31 |
32 | 33 | )} 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /packages/caspar-graphics/src/client/TemplatePreview.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | 3 | const States = { 4 | load: 0, 5 | loaded: 1, 6 | play: 2, 7 | stop: 3, 8 | } 9 | 10 | export const ServerTemplate = ({ socket, name, layer, show, data }) => { 11 | const [state, setState] = useState(States.load) 12 | const [prevUpdate, setPrevUpdate] = useState() 13 | const source = `/templates/${name}/index.html` 14 | const nextUpdate = 15 | data && JSON.stringify(data) !== JSON.stringify(prevUpdate || {}) 16 | ? data 17 | : null 18 | 19 | // Load 20 | useEffect(() => { 21 | if (socket && state === States.load) { 22 | socket.send(JSON.stringify({ type: 'load', source, layer })) 23 | setState(States.loaded) 24 | } 25 | }, [socket, source, state]) 26 | 27 | // Data Updates 28 | useEffect(() => { 29 | if ( 30 | socket && 31 | (state === States.loaded || state === States.play) && 32 | nextUpdate 33 | ) { 34 | socket.send(JSON.stringify({ type: 'update', layer, data: nextUpdate })) 35 | setPrevUpdate(nextUpdate) 36 | } 37 | }, [socket, state, nextUpdate]) 38 | 39 | // State Updates 40 | useEffect(() => { 41 | if (!socket) { 42 | return 43 | } 44 | 45 | if (show && state === States.loaded) { 46 | socket.send(JSON.stringify({ type: 'play', layer })) 47 | setState(States.play) 48 | } else if (!show && state === States.play) { 49 | socket.send(JSON.stringify({ type: 'stop', layer })) 50 | setState(States.stop) 51 | 52 | // TODO: wait for window.remove() to be called. 53 | const timeout = window.setTimeout(() => { 54 | setState(States.load) 55 | setPrevUpdate(null) 56 | }, 2000) 57 | 58 | return () => { 59 | window.clearTimeout(timeout) 60 | } 61 | } 62 | }, [socket, show, state]) 63 | 64 | return null 65 | } 66 | 67 | export const TemplatePreview = ({ 68 | dispatch, 69 | projectSize, 70 | containerSize, 71 | onKeyDown, 72 | manifest, 73 | name, 74 | src = `/templates/${name}/index.html`, 75 | show, 76 | data, 77 | }) => { 78 | const [templateWindow, setTemplateWindow] = useState() 79 | const [didShow, setDidShow] = useState(false) 80 | const ref = useRef() 81 | 82 | // Data Updates 83 | useEffect(() => { 84 | if (templateWindow?.update) { 85 | templateWindow.update(data || {}) 86 | } 87 | }, [templateWindow, data]) 88 | 89 | // State Updates 90 | useEffect(() => { 91 | if (!templateWindow) { 92 | return 93 | } 94 | 95 | if (show && !didShow) { 96 | templateWindow.play() 97 | setDidShow(true) 98 | } else if (!show && didShow) { 99 | if (templateWindow.stop) { 100 | templateWindow.stop() 101 | } else { 102 | console.warn('No window.stop() found') 103 | } 104 | } 105 | }, [templateWindow, show, didShow]) 106 | 107 | // Forward keybindings 108 | useEffect(() => { 109 | if (!templateWindow) { 110 | return 111 | } 112 | 113 | templateWindow.addEventListener('keydown', onKeyDown) 114 | 115 | return () => { 116 | templateWindow.removeEventListener('keydown', onKeyDown) 117 | } 118 | }, [templateWindow, onKeyDown]) 119 | 120 | let width = containerSize.width 121 | let height = containerSize.height 122 | 123 | if (manifest.width || manifest.height) { 124 | width = manifest.width 125 | height = manifest.height 126 | 127 | if (!width && height) { 128 | width = (containerSize.width / containerSize.height) * height 129 | } else if (width && !height) { 130 | height = (containerSize.height / containerSize.width) * width 131 | } 132 | } 133 | 134 | return ( 135 |