├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── assets ├── AtkinsonHyperlegible-Bold.woff2 ├── AtkinsonHyperlegible-BoldItalic.woff2 ├── AtkinsonHyperlegible-Italic.woff2 ├── AtkinsonHyperlegible-Regular.woff2 ├── SourceCodePro.woff2 ├── cohost-leaguemono.woff ├── examples │ ├── animated-svg-background.toml │ ├── css-animations.toml │ ├── default.toml │ ├── index.json │ ├── katex-mathml.toml │ ├── lesscss-files.toml │ ├── scss-files.toml │ ├── slideshow.toml │ ├── svelte-chat-log.toml │ ├── svelte-templates.toml │ └── svg-conditional-css.toml └── no-data.svg ├── flake.lock ├── flake.nix ├── index.html ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── cohost-inherited.scss ├── document.ts ├── index.tsx ├── plugins │ ├── index.ts │ ├── source │ │ ├── external-url.tsx │ │ ├── file-data-url.tsx │ │ ├── file-data.less │ │ ├── file-data.tsx │ │ ├── lesscss.css │ │ ├── lesscss.tsx │ │ ├── sass.tsx │ │ ├── svelte-component.tsx │ │ ├── svelte-worker.js │ │ ├── svelte.tsx │ │ ├── text.css │ │ └── text.tsx │ └── transform │ │ ├── style-inliner.tsx │ │ ├── svg-to-background.tsx │ │ ├── svgo.tsx │ │ ├── to-blob.tsx │ │ └── to-data-url.tsx ├── storage-context.tsx ├── storage │ ├── index.ts │ └── versions │ │ ├── index.ts │ │ ├── onv1 │ │ ├── index.ts │ │ ├── parse.ts │ │ └── stringify.ts │ │ ├── v0.ts │ │ └── v1.ts ├── ui │ ├── components │ │ ├── code-editor.css │ │ ├── code-editor.tsx │ │ ├── codemirror.tsx │ │ ├── data-preview.css │ │ ├── data-preview.tsx │ │ ├── icons.tsx │ │ ├── module-graph │ │ │ ├── auto-layout.ts │ │ │ ├── consts.ts │ │ │ ├── eggbug-sleep.svg │ │ │ ├── eggbug.svg │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── module-node.tsx │ │ │ ├── output-node.tsx │ │ │ └── reactflow.tsx │ │ ├── module-list.css │ │ ├── module-list.tsx │ │ ├── module-picker.css │ │ ├── module-picker.tsx │ │ ├── module-status.css │ │ ├── module-status.tsx │ │ ├── post-preview │ │ │ ├── basic-renderer.tsx │ │ │ ├── cohost-renderer.ts │ │ │ ├── dark-theme-button.css │ │ │ ├── dark-theme-button.tsx │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── preview.scss │ │ ├── preview.tsx │ │ ├── split-panel.scss │ │ └── split-panel.tsx │ ├── examples.css │ ├── examples.tsx │ ├── index.css │ ├── index.tsx │ ├── opt-held.tsx │ ├── prechoster.scss │ ├── prechoster.tsx │ ├── render-context.ts │ ├── sidebar.css │ └── sidebar.tsx └── uikit │ ├── animation.tsx │ ├── button-popout.css │ ├── button-popout.tsx │ ├── button.css │ ├── button.tsx │ ├── checkbox.css │ ├── checkbox.tsx │ ├── dir-popover.css │ ├── dir-popover.tsx │ ├── form.css │ ├── form.tsx │ ├── frame-animation.ts │ ├── layout-root-context.tsx │ ├── text-field.css │ └── text-field.tsx ├── tsconfig.json └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /result 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | static 4 | README.md 5 | src/cohost-inherited.scss 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 cpsdqs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prechoster 2 | A graph-based HTML generator to make fancy chosting easier. 3 | 4 | ## Overview 5 | Documents are a directed graph of modules. 6 | Every module is JSON data associated with a plugin implementation. 7 | The plugin implementation evaluates result `Data` and provides it to connected modules on the graph. 8 | Modules have “sends,” which simply input data into other modules in evaluation order, 9 | and “named sends,” which are sort of like side inputs that don’t make sense as regular inputs (e.g. variable definitions). 10 | 11 | Plugins are defined in `src/plugins` (indexed in `src/plugins/index.tsx`) and are composed of a module data interface, a UI component that edits module data, and an evaluation function. 12 | 13 | Do not change module data interfaces in a backwards-incompatible way because people are apparently using this software sometimes!! 14 | 15 | ### Building 16 | in the repository: 17 | 18 | ```sh 19 | npm install 20 | npm run build # or npm run dev 21 | ``` 22 | 23 | Look in `dist` for the output. 24 | 25 | ### Browser Support 26 | Major feature gates: 27 | 28 | - script type module 29 | - dialog element 30 | 31 | According to caniuse, this means: 32 | 33 | - Firefox 98 34 | - Safari 15.4 35 | - Chrome 63 36 | -------------------------------------------------------------------------------- /assets/AtkinsonHyperlegible-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpsdqs/prechoster/2c85906f9fd625ccb9001453244db4fcea6a900a/assets/AtkinsonHyperlegible-Bold.woff2 -------------------------------------------------------------------------------- /assets/AtkinsonHyperlegible-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpsdqs/prechoster/2c85906f9fd625ccb9001453244db4fcea6a900a/assets/AtkinsonHyperlegible-BoldItalic.woff2 -------------------------------------------------------------------------------- /assets/AtkinsonHyperlegible-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpsdqs/prechoster/2c85906f9fd625ccb9001453244db4fcea6a900a/assets/AtkinsonHyperlegible-Italic.woff2 -------------------------------------------------------------------------------- /assets/AtkinsonHyperlegible-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpsdqs/prechoster/2c85906f9fd625ccb9001453244db4fcea6a900a/assets/AtkinsonHyperlegible-Regular.woff2 -------------------------------------------------------------------------------- /assets/SourceCodePro.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpsdqs/prechoster/2c85906f9fd625ccb9001453244db4fcea6a900a/assets/SourceCodePro.woff2 -------------------------------------------------------------------------------- /assets/cohost-leaguemono.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpsdqs/prechoster/2c85906f9fd625ccb9001453244db4fcea6a900a/assets/cohost-leaguemono.woff -------------------------------------------------------------------------------- /assets/examples/animated-svg-background.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = 'Animated SVG Background' 3 | 4 | [[modules]] 5 | plugin = 'source.text' 6 | data.contents = ''' 7 | 8 | 9 | ''' 10 | data.language = 'html' 11 | sends = [2] 12 | 13 | [[modules]] 14 | plugin = 'source.lesscss' 15 | data.contents = ''' 16 | .animated-circle { 17 | transform-origin: 50% 50%; 18 | animation: pulse 2s infinite; 19 | } 20 | 21 | @keyframes pulse { 22 | 0%, 100% { 23 | transform: scale(0.5); 24 | } 25 | 50% { 26 | transform: scale(1); 27 | } 28 | }''' 29 | sends = [2] 30 | 31 | [[modules]] 32 | plugin = 'transform.style-inliner' 33 | data.mode = 'element' 34 | sends = [3] 35 | 36 | [[modules]] 37 | plugin = 'transform.to-data-url' 38 | data.mime = 'image/svg+xml' 39 | namedSends = { '5' = ['svg'] } 40 | 41 | [[modules]] 42 | plugin = 'source.text' 43 | data.contents = '
' 44 | data.language = 'html' 45 | sends = [6] 46 | 47 | [[modules]] 48 | plugin = 'source.lesscss' 49 | data.contents = ''' 50 | .svg-container { 51 | width: 200px; 52 | height: 200px; 53 | background: url("@{svg}"); 54 | }''' 55 | sends = [6] 56 | 57 | [[modules]] 58 | plugin = 'transform.style-inliner' 59 | data.mode = 'attr' 60 | sends = ['output'] 61 | -------------------------------------------------------------------------------- /assets/examples/css-animations.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = 'CSS Animations' 3 | 4 | [[modules]] 5 | plugin = 'source.text' 6 | data.language = 'html' 7 | data.contents = ''' 8 | ## CSS animations available on Cohost 9 | While you cannot define your own `@keyframes` with inline CSS on Cohost, you can still call existing animations defined elsewhere on the page. Use them wisely… 10 | 11 | ### Always available 12 | 13 | 54 | 55 | 56 | 57 | ### Available only when reduced motion is disabled 58 | 59 |
60 | Reduced motion is currently: 61 | 62 | disabled 63 | enabled 64 | 65 | 66 |
67 | 68 | 86 | 87 | ### Available only when reduced motion is enabled 88 | ''' 94 | sends = [2] 95 | 96 | [[modules]] 97 | plugin = 'source.text' 98 | data.contents = ''' 99 | ul.demos { 100 | display: grid; 101 | list-style: none; 102 | padding: 0; 103 | gap: 1em; 104 | grid-template-columns: repeat(auto-fit, minmax(10em, 1fr)); 105 | } 106 | 107 | ul.demos > li { 108 | display: flex; 109 | flex-direction: column; 110 | gap: 1em; 111 | align-items: center; 112 | padding: 1em; 113 | background: #7772; 114 | border: 1px solid #7777; 115 | border-radius: 0.5em; 116 | } 117 | 118 | .demo-box { 119 | background: gray; 120 | color: white; 121 | width: 3rem; 122 | height: 3rem; 123 | display: grid; 124 | place-content: center; 125 | font-size: 0.7em; 126 | line-height: 1.1; 127 | } 128 | 129 | .reduced-motion-check { 130 | display: inline-block; 131 | overflow: hidden; 132 | font-weight: bold; 133 | height: 1.25em; 134 | position: relative; 135 | line-height: 1; 136 | vertical-align: middle; 137 | } 138 | .reduced-motion-check .a { 139 | display: inline-block; 140 | width: 100%; 141 | position: relative; 142 | transform: translateX(-100%); 143 | animation: spin 0s forwards; 144 | color: #07f; 145 | } 146 | .reduced-motion-check .b { 147 | position: absolute; 148 | left: 100%; 149 | color: #7a0; 150 | } 151 | ''' 152 | data.language = 'css' 153 | sends = [2] 154 | 155 | [[modules]] 156 | plugin = 'transform.style-inliner' 157 | data.mode = 'attr' 158 | sends = ['output'] 159 | -------------------------------------------------------------------------------- /assets/examples/default.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = "Style Inlining" 3 | 4 | [[modules]] 5 | plugin = "source.text" 6 | data.contents = ''' 7 |
8 | hello world 9 |
10 | 11 |

12 | see Examples and Templates in the 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | sidebar for more examples! 27 |

''' 28 | data.language = "html" 29 | sends = [2] 30 | 31 | [[modules]] 32 | plugin = "source.text" 33 | data.contents = ''' 34 | .a-thing { 35 | color: red; 36 | } 37 | 38 | .toolbar-button { 39 | display: inline-flex; 40 | vertical-align: middle; 41 | 42 | align-items: center; 43 | justify-content: center; 44 | padding: 0 0.1em; 45 | 46 | background: black; 47 | border: 1px solid #fff4; 48 | border-radius: 0.5em; 49 | } 50 | .sidebar-icon { 51 | display: block; 52 | vertical-align: middle; 53 | width: 24px; 54 | aspect-ratio: 1; 55 | background-size: contain; 56 | }''' 57 | data.language = "css" 58 | sends = [2] 59 | 60 | [[modules]] 61 | plugin = "transform.style-inliner" 62 | data.mode = "attr" 63 | sends = [3] 64 | 65 | [[modules]] 66 | plugin = "transform.svg-to-background" 67 | data.useSvgo = true 68 | sends = ["output"] 69 | -------------------------------------------------------------------------------- /assets/examples/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "default.toml": { 3 | "title": "Style Inlining", 4 | "description": "Inline a CSS stylesheet into HTML." 5 | }, 6 | "css-animations.toml": { 7 | "title": "CSS Animations", 8 | "description": "Use predefined animations available on Cohost." 9 | }, 10 | "lesscss-files.toml": { 11 | "title": "LessCSS Variables", 12 | "description": "Use LessCSS variables to include files as background images." 13 | }, 14 | "scss-files.toml": { 15 | "title": "Sass Modules", 16 | "description": "Use Sass modules to include files as background images." 17 | }, 18 | "svelte-templates.toml": { 19 | "title": "Svelte Templating", 20 | "description": "Use Svelte components for various kinds of templating." 21 | }, 22 | "svelte-chat-log.toml": { 23 | "title": "Svelte Chat Log", 24 | "description": "Use Svelte to create a simple chat log template." 25 | }, 26 | "slideshow.toml": { 27 | "title": "Slideshow", 28 | "description": "Use Svelte and
to create a slideshow." 29 | }, 30 | "katex-mathml.toml": { 31 | "title": "KaTeX MathML", 32 | "description": "Use KaTeX to easily render TeX markup to MathML." 33 | }, 34 | "animated-svg-background.toml": { 35 | "title": "Animated SVG Background", 36 | "description": "Use @keyframes to animate SVG elements and embed them as a background image." 37 | }, 38 | "svg-conditional-css.toml": { 39 | "title": "Conditional CSS inside SVG", 40 | "description": "Use SVG backgrounds to trigger CSS animation with a click interaction." 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /assets/examples/katex-mathml.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = 'KaTeX MathML' 3 | 4 | [[modules]] 5 | plugin = 'source.text' 6 | data.contents = ''' 7 | here’s some math: 8 | $$f*g = \int_{-\infty}^\infty f(t)\,g(x-t)\,dt$$ 9 | 10 | and some inline math also: $e^{\tau i}=1$ 11 | 12 |
13 |
14 | $$ 15 | \left[\begin{matrix} 16 | \cos\theta & -\sin\theta \\ 17 | \sin\theta & \cos\theta 18 | \end{matrix}\right] 19 | $$ 20 |
21 |
''' 22 | data.language = 'html' 23 | namedSends = { '4' = ['text'] } 24 | 25 | [[modules]] 26 | plugin = 'source.text' 27 | data.contents = ''' 28 | .spin-container { 29 | text-align: center; 30 | padding: 1em 0; 31 | } 32 | 33 | .spinning { 34 | display: inline-block; 35 | animation: spin 10s infinite linear; 36 | }''' 37 | data.language = 'css' 38 | sends = [2] 39 | 40 | [[modules]] 41 | plugin = 'transform.style-inliner' 42 | data.mode = 'attr' 43 | sends = [3] 44 | 45 | [[modules]] 46 | plugin = 'transform.svg-to-background' 47 | data.useSvgo = true 48 | sends = ['output'] 49 | 50 | [[modules]] 51 | plugin = 'source.svelte' 52 | data.contents = ''' 53 | 79 | {@html rendered}''' 80 | sends = [2] 81 | 82 | [[modules]] 83 | plugin = 'source.external-url-data' 84 | data.url = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.mjs' 85 | data.type = 'javascript' 86 | namedSends = { '4' = ['katex.js'] } 87 | -------------------------------------------------------------------------------- /assets/examples/lesscss-files.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = 'LessCSS Variables' 3 | 4 | [[modules]] 5 | plugin = 'source.text' 6 | data.contents = ''' 7 |
8 | check it out → 9 |
''' 10 | data.language = 'html' 11 | sends = [3] 12 | 13 | [[modules]] 14 | plugin = 'source.file-data-url' 15 | data.url = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDE2OCAxODIiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgeG1sbnM6c2VyaWY9Imh0dHA6Ly93d3cuc2VyaWYuY29tLyIgc3R5bGU9ImZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjI7Ij4KICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEsMCwwLDEsLTQ5LC0zMikiPgogICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDAuMjU2MTkyLDAsMCwwLjI2OTAzLDg2NS44MzEsMjU4LjAzNikiPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgtMS4zMDAzMSwwLDAsMS4yODI0LC01MjA0LjQyLC0xNS4xMTc5KSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNLTE2MDMsLTIwNC41NzFDLTE1NzguNTgsLTIwNC4yODEgLTE1NjguNjQsLTE4OS41MDIgLTE1NjUuOTMsLTE2Ny41MDIiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOnJnYigyNSwyNSwyNSk7c3Ryb2tlLXdpZHRoOjExLjM4cHg7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoLTEuMjk3MywtMC45NTM1ODIsLTAuOTY2OTAyLDEuMjc5NDMsLTQ2MTUuOTksLTgxNS44OSkiPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTS05MzksLTQ4NkMtOTM5LC00ODYgLTkwMi4wOTcsLTY1OS45MDYgLTc5NiwtNTk3Qy02ODkuOTAzLC01MzQuMDk0IC05MjYuMDg4LC00NjYuNzg5IC05MjksLTQ2OCIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6cmdiKDI1LDI1LDI1KTtzdHJva2Utd2lkdGg6OS4zcHg7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoODE2LjY1MywtMjQwLjY5OSwyNDQuMDYxLDgwNS40MDMsLTMyMjYuOTIsLTExMC42OTQpIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjUyNSwwLjAxMUMwLjQ1MywwLjAxMSAwLjM4OCwwLjAwMSAwLjMyOSwtMC4wMThDMC4yNzEsLTAuMDM4IDAuMjI0LC0wLjA2NyAwLjE5LC0wLjEwNkMwLjE1NSwtMC4xNDQgMC4xMzgsLTAuMTkzIDAuMTM4LC0wLjI1MUMwLjEzOCwtMC4zMSAwLjE1NSwtMC4zNTggMC4xOSwtMC4zOTdDMC4yMjQsLTAuNDM1IDAuMjcxLC0wLjQ2NCAwLjMyOSwtMC40ODNDMC4zODgsLTAuNTAyIDAuNDUzLC0wLjUxMiAwLjUyNSwtMC41MTJDMC41OTcsLTAuNTEyIDAuNjYyLC0wLjUwMiAwLjcyLC0wLjQ4M0MwLjc3OCwtMC40NjQgMC44MjUsLTAuNDM1IDAuODYsLTAuMzk2QzAuODk0LC0wLjM1OCAwLjkxMiwtMC4zMDkgMC45MTIsLTAuMjUxQzAuOTEyLC0wLjE5MyAwLjg5NCwtMC4xNDQgMC44NiwtMC4xMDZDMC44MjUsLTAuMDY3IDAuNzc4LC0wLjAzOCAwLjcyLC0wLjAxOEMwLjY2MiwwLjAwMSAwLjU5NywwLjAxMSAwLjUyNSwwLjAxMVoiIHN0eWxlPSJmaWxsOnJnYigxMzEsMzcsNzkpO2ZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEuMzAwMzEsMCwwLDEuMjgyNCwtOTMyLjM4MSwtMzA4LjAyKSI+CiAgICAgICAgICAgICAgICA8Y2lyY2xlIGN4PSItMTY2OS41IiBjeT0iLTc0LjUiIHI9IjE3LjUiIHN0eWxlPSJmaWxsOnJnYigyNSwyNSwyNSk7c3Ryb2tlOnJnYigyNSwyNSwyNSk7c3Ryb2tlLXdpZHRoOjExLjM4cHg7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDsiLz4KICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjMwMDMxLDAsMCwxLjI4MjQsLTgwMS4wNSwtMjg1LjU3OCkiPgogICAgICAgICAgICAgICAgPGNpcmNsZSBjeD0iLTE2NjkuNSIgY3k9Ii03NC41IiByPSIxNy41IiBzdHlsZT0iZmlsbDpyZ2IoMjUsMjUsMjUpO3N0cm9rZTpyZ2IoMjUsMjUsMjUpO3N0cm9rZS13aWR0aDoxMS4zOHB4O3N0cm9rZS1saW5lY2FwOnNxdWFyZTtzdHJva2UtbGluZWpvaW46cm91bmQ7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMS42MTc5OSwwLDAsMS41OTU3LC0xMzE2LjYzLDIyMy4yNjMpIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0tOTM5LC00ODZDLTkzOSwtNDg2IC05MDIuMDk3LC02NTkuOTA2IC03OTYsLTU5N0MtNjg5LjkwMywtNTM0LjA5NCAtOTI2LjA4OCwtNDY2Ljc4OSAtOTI5LC00NjgiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOnJnYigyNSwyNSwyNSk7c3Ryb2tlLXdpZHRoOjkuMTVweDsiLz4KICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjMwMDMxLDAsMCwxLjI4MjQsLTc3NS42MjksMzQuMTM4NikiPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTS0xNjAzLC0yMDQuNTcxQy0xNTc4LjU4LC0yMDQuMjgxIC0xNTY4LjY0LC0xODkuNTAyIC0xNTY1LjkzLC0xNjcuNTAyIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTpyZ2IoMjUsMjUsMjUpO3N0cm9rZS13aWR0aDoxMS4zOHB4OyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEuMzAwMzEsMCwwLDEuMjgyNCwtNjYxLjg4OCwtMTMuMzk5OCkiPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTS0xNjAzLC0yMDQuNTcxQy0xNTc4LjU4LC0yMDQuMjgxIC0xNTY4LjY0LC0xODkuNTAyIC0xNTY1LjkzLC0xNjcuNTAyIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTpyZ2IoMjUsMjUsMjUpO3N0cm9rZS13aWR0aDoxMS4zOHB4OyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEuMjU3NywtMC4zMjU1OTksMC4zMzAxNDcsMS4yNDAzNywtNTc3LjYwMiwtNTc5LjU2KSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNLTE2MDMsLTIwNC41NzFDLTE1NzguNTgsLTIwNC4yODEgLTE1NjguNjQsLTE4OS41MDIgLTE1NjUuOTMsLTE2Ny41MDIiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOnJnYigyNSwyNSwyNSk7c3Ryb2tlLXdpZHRoOjExLjQ4cHg7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMC44NDcyMDMsMC4wNjA3Nzc4LC0wLjA2MTYyNjcsMC44MzU1MzMsLTIwNDguMDcsLTE3MS4zODUpIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0tMTIyNSwtMTIwQy0xMjI0LjkyLC0xMTguODg3IC0xMjEwLjk5LC0xMDUuNTIzIC0xMTk0LC0xMjBDLTExOTMuNDcsLTEyMC4xMzYgLTExNzkuNTMsLTEwNi4yNDEgLTExNjMuMTQsLTExOS4xNCIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6cmdiKDI1LDI1LDI1KTtzdHJva2Utd2lkdGg6MTcuMzhweDtzdHJva2UtbGluZWpvaW46cm91bmQ7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPgo=' 16 | namedSends = { '2' = ['eggbug'] } 17 | 18 | [[modules]] 19 | plugin = 'source.lesscss' 20 | data.contents = ''' 21 | .a-thing { 22 | background: url(@eggbug); 23 | background-repeat: no-repeat; 24 | background-position: right center; 25 | }''' 26 | sends = [3] 27 | 28 | [[modules]] 29 | plugin = 'transform.style-inliner' 30 | data.mode = 'attr' 31 | sends = ['output'] 32 | -------------------------------------------------------------------------------- /assets/examples/scss-files.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = 'Sass Modules' 3 | 4 | [[modules]] 5 | plugin = 'source.text' 6 | data.contents = ''' 7 |
8 | check it out → 9 |
''' 10 | data.language = 'html' 11 | sends = [2] 12 | 13 | [[modules]] 14 | plugin = 'source.file-data-url' 15 | data.url = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDE2OCAxODIiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgeG1sbnM6c2VyaWY9Imh0dHA6Ly93d3cuc2VyaWYuY29tLyIgc3R5bGU9ImZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjI7Ij4KICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEsMCwwLDEsLTQ5LC0zMikiPgogICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDAuMjU2MTkyLDAsMCwwLjI2OTAzLDg2NS44MzEsMjU4LjAzNikiPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgtMS4zMDAzMSwwLDAsMS4yODI0LC01MjA0LjQyLC0xNS4xMTc5KSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNLTE2MDMsLTIwNC41NzFDLTE1NzguNTgsLTIwNC4yODEgLTE1NjguNjQsLTE4OS41MDIgLTE1NjUuOTMsLTE2Ny41MDIiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOnJnYigyNSwyNSwyNSk7c3Ryb2tlLXdpZHRoOjExLjM4cHg7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoLTEuMjk3MywtMC45NTM1ODIsLTAuOTY2OTAyLDEuMjc5NDMsLTQ2MTUuOTksLTgxNS44OSkiPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTS05MzksLTQ4NkMtOTM5LC00ODYgLTkwMi4wOTcsLTY1OS45MDYgLTc5NiwtNTk3Qy02ODkuOTAzLC01MzQuMDk0IC05MjYuMDg4LC00NjYuNzg5IC05MjksLTQ2OCIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6cmdiKDI1LDI1LDI1KTtzdHJva2Utd2lkdGg6OS4zcHg7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoODE2LjY1MywtMjQwLjY5OSwyNDQuMDYxLDgwNS40MDMsLTMyMjYuOTIsLTExMC42OTQpIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0wLjUyNSwwLjAxMUMwLjQ1MywwLjAxMSAwLjM4OCwwLjAwMSAwLjMyOSwtMC4wMThDMC4yNzEsLTAuMDM4IDAuMjI0LC0wLjA2NyAwLjE5LC0wLjEwNkMwLjE1NSwtMC4xNDQgMC4xMzgsLTAuMTkzIDAuMTM4LC0wLjI1MUMwLjEzOCwtMC4zMSAwLjE1NSwtMC4zNTggMC4xOSwtMC4zOTdDMC4yMjQsLTAuNDM1IDAuMjcxLC0wLjQ2NCAwLjMyOSwtMC40ODNDMC4zODgsLTAuNTAyIDAuNDUzLC0wLjUxMiAwLjUyNSwtMC41MTJDMC41OTcsLTAuNTEyIDAuNjYyLC0wLjUwMiAwLjcyLC0wLjQ4M0MwLjc3OCwtMC40NjQgMC44MjUsLTAuNDM1IDAuODYsLTAuMzk2QzAuODk0LC0wLjM1OCAwLjkxMiwtMC4zMDkgMC45MTIsLTAuMjUxQzAuOTEyLC0wLjE5MyAwLjg5NCwtMC4xNDQgMC44NiwtMC4xMDZDMC44MjUsLTAuMDY3IDAuNzc4LC0wLjAzOCAwLjcyLC0wLjAxOEMwLjY2MiwwLjAwMSAwLjU5NywwLjAxMSAwLjUyNSwwLjAxMVoiIHN0eWxlPSJmaWxsOnJnYigxMzEsMzcsNzkpO2ZpbGwtcnVsZTpub256ZXJvOyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEuMzAwMzEsMCwwLDEuMjgyNCwtOTMyLjM4MSwtMzA4LjAyKSI+CiAgICAgICAgICAgICAgICA8Y2lyY2xlIGN4PSItMTY2OS41IiBjeT0iLTc0LjUiIHI9IjE3LjUiIHN0eWxlPSJmaWxsOnJnYigyNSwyNSwyNSk7c3Ryb2tlOnJnYigyNSwyNSwyNSk7c3Ryb2tlLXdpZHRoOjExLjM4cHg7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlO3N0cm9rZS1saW5lam9pbjpyb3VuZDsiLz4KICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjMwMDMxLDAsMCwxLjI4MjQsLTgwMS4wNSwtMjg1LjU3OCkiPgogICAgICAgICAgICAgICAgPGNpcmNsZSBjeD0iLTE2NjkuNSIgY3k9Ii03NC41IiByPSIxNy41IiBzdHlsZT0iZmlsbDpyZ2IoMjUsMjUsMjUpO3N0cm9rZTpyZ2IoMjUsMjUsMjUpO3N0cm9rZS13aWR0aDoxMS4zOHB4O3N0cm9rZS1saW5lY2FwOnNxdWFyZTtzdHJva2UtbGluZWpvaW46cm91bmQ7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMS42MTc5OSwwLDAsMS41OTU3LC0xMzE2LjYzLDIyMy4yNjMpIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0tOTM5LC00ODZDLTkzOSwtNDg2IC05MDIuMDk3LC02NTkuOTA2IC03OTYsLTU5N0MtNjg5LjkwMywtNTM0LjA5NCAtOTI2LjA4OCwtNDY2Ljc4OSAtOTI5LC00NjgiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOnJnYigyNSwyNSwyNSk7c3Ryb2tlLXdpZHRoOjkuMTVweDsiLz4KICAgICAgICAgICAgPC9nPgogICAgICAgICAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjMwMDMxLDAsMCwxLjI4MjQsLTc3NS42MjksMzQuMTM4NikiPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTS0xNjAzLC0yMDQuNTcxQy0xNTc4LjU4LC0yMDQuMjgxIC0xNTY4LjY0LC0xODkuNTAyIC0xNTY1LjkzLC0xNjcuNTAyIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTpyZ2IoMjUsMjUsMjUpO3N0cm9rZS13aWR0aDoxMS4zOHB4OyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEuMzAwMzEsMCwwLDEuMjgyNCwtNjYxLjg4OCwtMTMuMzk5OCkiPgogICAgICAgICAgICAgICAgPHBhdGggZD0iTS0xNjAzLC0yMDQuNTcxQy0xNTc4LjU4LC0yMDQuMjgxIC0xNTY4LjY0LC0xODkuNTAyIC0xNTY1LjkzLC0xNjcuNTAyIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTpyZ2IoMjUsMjUsMjUpO3N0cm9rZS13aWR0aDoxMS4zOHB4OyIvPgogICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDxnIHRyYW5zZm9ybT0ibWF0cml4KDEuMjU3NywtMC4zMjU1OTksMC4zMzAxNDcsMS4yNDAzNywtNTc3LjYwMiwtNTc5LjU2KSI+CiAgICAgICAgICAgICAgICA8cGF0aCBkPSJNLTE2MDMsLTIwNC41NzFDLTE1NzguNTgsLTIwNC4yODEgLTE1NjguNjQsLTE4OS41MDIgLTE1NjUuOTMsLTE2Ny41MDIiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOnJnYigyNSwyNSwyNSk7c3Ryb2tlLXdpZHRoOjExLjQ4cHg7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMC44NDcyMDMsMC4wNjA3Nzc4LC0wLjA2MTYyNjcsMC44MzU1MzMsLTIwNDguMDcsLTE3MS4zODUpIj4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0tMTIyNSwtMTIwQy0xMjI0LjkyLC0xMTguODg3IC0xMjEwLjk5LC0xMDUuNTIzIC0xMTk0LC0xMjBDLTExOTMuNDcsLTEyMC4xMzYgLTExNzkuNTMsLTEwNi4yNDEgLTExNjMuMTQsLTExOS4xNCIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6cmdiKDI1LDI1LDI1KTtzdHJva2Utd2lkdGg6MTcuMzhweDtzdHJva2UtbGluZWpvaW46cm91bmQ7Ii8+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPgo=' 16 | namedSends = { '3' = ['eggbug'] } 17 | 18 | [[modules]] 19 | plugin = 'transform.style-inliner' 20 | data.mode = 'attr' 21 | sends = ['output'] 22 | 23 | [[modules]] 24 | plugin = 'source.sass' 25 | data.contents = ''' 26 | @use "./eggbug"; 27 | @use "./mixins.scss"; 28 | 29 | .a-thing { 30 | @include mixins.example-mixin(eggbug.$value); 31 | }''' 32 | data.syntax = 'scss' 33 | sends = [2] 34 | 35 | [[modules]] 36 | plugin = 'source.sass-module' 37 | data.contents = ''' 38 | @mixin example-mixin($url) { 39 | background: url("#{$url}"); 40 | background-repeat: no-repeat; 41 | background-position: right center; 42 | }''' 43 | data.syntax = 'scss' 44 | namedSends = { '3' = ['mixins'] } 45 | -------------------------------------------------------------------------------- /assets/examples/slideshow.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = 'Slideshow' 3 | 4 | [[modules]] 5 | plugin = 'source.svelte' 6 | data.contents = ''' 7 | 10 | 11 |
12 | slide one 13 | 14 |
15 | slide two 16 |
17 | 18 |
19 | slide three 20 |
21 | 22 |
23 | slide four 24 |
25 |
26 |
27 |
28 |
''' 29 | sends = [3] 30 | 31 | [[modules]] 32 | plugin = 'source.sass' 33 | data.contents = ''' 34 | .slideshow-container { 35 | $background-color: #333; 36 | color: white; 37 | 38 | $aspect-ratio: 6 / 4; 39 | $slide-button-width: 2rem; 40 | $slide-button-height: 2rem; 41 | $slide-button-text-size: 2rem; 42 | 43 | position: relative; 44 | aspect-ratio: $aspect-ratio; 45 | background: $background-color; 46 | 47 | .slide { 48 | position: absolute; 49 | left: 0; 50 | right: 0; 51 | bottom: 0; 52 | text-align: right; 53 | } 54 | 55 | .next-slide-button { 56 | display: inline-grid; 57 | place-content: center; 58 | pointer-events: all; 59 | width: $slide-button-width; 60 | height: $slide-button-height; 61 | font-size: 0; 62 | vertical-align: bottom; 63 | cursor: default; 64 | 65 | .text-contents { 66 | font-size: $slide-button-text-size; 67 | } 68 | } 69 | 70 | .fake-prev-slide-button { 71 | position: absolute; 72 | display: inline-grid; 73 | place-content: center; 74 | width: $slide-button-width; 75 | height: $slide-button-height; 76 | right: $slide-button-width; 77 | font-size: $slide-button-text-size; 78 | bottom: 0; 79 | } 80 | 81 | .displace-prev-slide-button { 82 | display: inline-block; 83 | width: $slide-button-width; 84 | } 85 | 86 | .slide-shroud { 87 | text-align: left; 88 | background: $background-color; 89 | position: absolute; 90 | pointer-events: none; 91 | width: 100%; 92 | aspect-ratio: $aspect-ratio; 93 | bottom: 0; 94 | } 95 | 96 | .slide-contents { 97 | position: absolute; 98 | inset: 0; 99 | bottom: $slide-button-height; 100 | pointer-events: all; 101 | } 102 | }''' 103 | data.syntax = 'scss' 104 | sends = [3] 105 | 106 | [[modules]] 107 | plugin = 'source.svelte-component' 108 | data.name = 'Slide' 109 | data.contents = ''' 110 |
111 | 112 | 113 | 114 |
115 |
116 |
117 | 118 |
119 |
''' 120 | sends = [0] 121 | 122 | [[modules]] 123 | plugin = 'transform.style-inliner' 124 | data.mode = 'attr' 125 | sends = ['output'] 126 | -------------------------------------------------------------------------------- /assets/examples/svelte-chat-log.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = 'Svelte Chat Log' 3 | 4 | [[modules]] 5 | plugin = 'source.text' 6 | data.contents = ''' 7 | eggbug: egg! 8 | bug! 9 | eggbug2: egg bug!''' 10 | data.language = 'text' 11 | namedSends = { '2' = ['chat-log'] } 12 | 13 | [[modules]] 14 | plugin = 'source.lesscss' 15 | data.contents = ''' 16 | .sender-picture { 17 | /* 18 | * if you want to actually use this code, 19 | * consider uploading your images to cohost 20 | * and using a cohostcdn URL instead of 21 | * inlining them into the CSS like this. 22 | * you might hit the post size limit (~200kB) 23 | * otherwise! 24 | */ 25 | &[data-sender="eggbug"] { 26 | background-image: url("@{eggbug}"); 27 | } 28 | &[data-sender="eggbug2"] { 29 | background-image: url("@{eggbug}"); 30 | } 31 | } 32 | 33 | .chat-log { 34 | list-style: none; 35 | padding: 0; 36 | } 37 | .chat-message { 38 | margin: 0; 39 | margin-top: 0.5em; 40 | display: flex; 41 | line-height: 1.2; 42 | padding: 0; 43 | @picture-size: 2em; 44 | @gap: 0.5em; 45 | gap: @gap; 46 | 47 | &.is-continuation { 48 | padding-left: @picture-size + @gap; 49 | margin-top: 0; 50 | } 51 | 52 | .sender-picture { 53 | width: @picture-size; 54 | height: @picture-size; 55 | border-radius: 50%; 56 | background-size: cover; 57 | background-position: center; 58 | background-repeat: no-repeat; 59 | flex-shrink: 0; 60 | } 61 | 62 | .sender-header { 63 | font-weight: bold; 64 | } 65 | .message-contents { 66 | line-height: 1.5; 67 | } 68 | } 69 | ''' 70 | sends = [4] 71 | 72 | [[modules]] 73 | plugin = 'source.svelte' 74 | data.contents = ''' 75 | 109 | 110 | ''' 131 | sends = [4] 132 | 133 | [[modules]] 134 | plugin = 'source.file-data-url' 135 | data.url = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYzIiBoZWlnaHQ9IjE2NyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48bGluZWFyR3JhZGllbnQgeDE9IjUwJSIgeTE9IjAlIiB4Mj0iNTAlIiB5Mj0iMTAwJSIgaWQ9ImEiPjxzdG9wIHN0b3AtY29sb3I9IiNEQUFDOTMiIG9mZnNldD0iMCUiLz48c3RvcCBzdG9wLWNvbG9yPSIjNjQxRDM0IiBvZmZzZXQ9IjEwMCUiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGZpbGw9InVybCgjYSkiIGQ9Ik0wIDBoMTYzdjE2N0gweiIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0uNDYyIC0uMDEyKSI+PHBhdGggZD0iTTQxLjg4MiAxMDkuMTI1Yy00Ljg0Mi4wNi02LjgxMyAzLjA5NS03LjM1IDcuNjEzIiBzdHJva2U9IiMxOTE5MTkiIHN0cm9rZS13aWR0aD0iMi4yOTgiLz48cGF0aCBkPSJNNzAuNzMgNjYuMjhzMTkuNTAzLTQxLjE4Ny0xMS4wMzMtNDMuODZjLTMwLjUzNi0yLjY3MSA1LjUzOSA0NS45MSA2LjI5OCA0Ni4wOSIgc3Ryb2tlPSIjMTkxOTE5IiBzdHJva2Utd2lkdGg9IjIuMzM3Ii8+PHBhdGggZD0iTTkxLjgzOCAxMTcuMDJjLTguOTQ4IDIuNzA1LTE3LjQxNiAzLjg1OC0yNS40OSAzLjYyMy03Ljk4Ny0uNC0xNC45Ni0yLjM3NS0yMC43MDYtNi4xMjktNS44MzEtMy41ODctOS44NTUtOS4yNjktMTIuMTE3LTE2Ljc1LTIuMzAxLTcuNjEyLTIuMDYtMTQuNDQyLjc2OC0yMC43ODkgMi43NDMtNi4xNzkgNy40NTMtMTEuNjg2IDEzLjkyLTE2LjMxNiA2LjU5MS00LjY2OCAxNC4yOC04LjQgMjMuMjI3LTExLjEwNiA4Ljk0OC0yLjcwNSAxNy40MTYtMy44NTcgMjUuMzY1LTMuNTg2IDcuOTUuMjcyIDE0LjkyMSAyLjI0NyAyMC43OTIgNS45NjMgNS43MDcgMy42MjQgOS44NTYgOS4yNyAxMi4xMTggMTYuNzUxIDIuMjYyIDcuNDgyIDEuOTM2IDE0LjQ4LS44MDggMjAuNjU5LTIuODI4IDYuMzQ2LTcuNTM4IDExLjg1My0xMy45NjYgMTYuNjEyLTYuNDY3IDQuNjMtMTQuMTU1IDguMzYzLTIzLjEwMyAxMS4wNjhaIiBmaWxsPSIjODMyNTRGIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48ZWxsaXBzZSBzdHJva2U9IiMxOTE5MTkiIHN0cm9rZS13aWR0aD0iMi4yOTgiIGZpbGw9IiMxOTE5MTkiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGN4PSI0NC40NCIgY3k9Ijg4LjkzMiIgcng9IjMuNDciIHJ5PSIzLjU5NCIvPjxlbGxpcHNlIHN0cm9rZT0iIzE5MTkxOSIgc3Ryb2tlLXdpZHRoPSIyLjI5OCIgZmlsbD0iIzE5MTkxOSIgc3Ryb2tlLWxpbmVjYXA9InNxdWFyZSIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgY3g9IjY0LjQ2OCIgY3k9IjkyLjUyNiIgcng9IjMuNDciIHJ5PSIzLjU5NCIvPjxwYXRoIGQ9Ik04NS4yMDYgNjUuMTIyczkuMTA2LTQ0LjQzOCAzNS4yODMtMjguMzY0YzI2LjE3OCAxNi4wNzQtMzIuMDk3IDMzLjI3My0zMi44MTUgMzIuOTYzIiBzdHJva2U9IiMxOTE5MTkiIHN0cm9rZS13aWR0aD0iMi4yOTkiLz48cGF0aCBkPSJNODEuNTMgMTE3LjAxM2M0Ljg0My4wNiA2LjgxNCAzLjA5NSA3LjM1MSA3LjYxM005OC44NzYgMTA5LjRjNC44NDIuMDYgNi44MTMgMy4wOTUgNy4zNSA3LjYxMyIgc3Ryb2tlPSIjMTkxOTE5IiBzdHJva2Utd2lkdGg9IjIuMjk4Ii8+PHBhdGggZD0iTTExMS44MDYgMTAzLjY3MWM0LjY5MS0xLjE4NCA3LjM3NSAxLjI0NiA5LjA1NSA1LjQ3OCIgc3Ryb2tlPSIjMTkxOTE5IiBzdHJva2Utd2lkdGg9IjIuMzE4Ii8+PHBhdGggZD0iTTQ4LjIxNyA5OC4xNGMwIC4xNSAxLjY2NyAyLjA3IDQuMDA0LjI5NS4wNy0uMDEzIDEuNzM0IDEuOTc5IDMuOTc4LjQxIiBzdHJva2U9IiMxOTE5MTkiIHN0cm9rZS13aWR0aD0iMi4yOTMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvZz48L2c+PC9zdmc+' 136 | namedSends = { '1' = ['eggbug'] } 137 | 138 | [[modules]] 139 | plugin = 'transform.style-inliner' 140 | data.mode = 'attr' 141 | sends = ['output'] 142 | -------------------------------------------------------------------------------- /assets/examples/svelte-templates.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = 'Svelte Templating' 3 | 4 | [[modules]] 5 | plugin = 'source.svelte' 6 | data.contents = ''' 7 | 11 | 12 | 13 | Hello {textData.toUpperCase()} 14 | ''' 15 | sends = [3] 16 | 17 | [[modules]] 18 | plugin = 'source.text' 19 | data.contents = 'world!' 20 | data.language = 'text' 21 | namedSends = { '0' = ['text-data'] } 22 | 23 | [[modules]] 24 | plugin = 'source.svelte-component' 25 | data.name = 'Box' 26 | data.contents = ''' 27 |
28 | 29 |
30 | 31 | ''' 40 | sends = [0] 41 | 42 | [[modules]] 43 | plugin = 'transform.style-inliner' 44 | data.mode = 'attr' 45 | sends = ['output'] 46 | -------------------------------------------------------------------------------- /assets/examples/svg-conditional-css.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | title = 'Conditional CSS inside SVG' 3 | 4 | [[modules]] 5 | plugin = 'source.text' 6 | data.contents = ''' 7 | ### triggering conditional CSS inside SVG backgrounds 8 | 9 | The <details> element here has a size of 200×200 pixels, but when opened, it has a size of 200×201 pixels (because of the .inner-spacer element). 10 | 11 | By using an SVG background that does not have a viewBox and a background-size of 100%, the browser will actually pass the size of the container to the SVG image. So, inside the SVG (module #6 LessCSS), we can check for the case where the container is larger than 200 pixels, and apply a simple animation (or a more complex animation…) 12 | 13 |
14 | 15 |
16 |
''' 17 | data.language = 'html' 18 | sends = [2] 19 | 20 | [[modules]] 21 | plugin = 'source.lesscss' 22 | data.contents = ''' 23 | .svg-container { 24 | display: inline-block; 25 | background: url("@{svg}") top left no-repeat; 26 | background-size: 100% 100%; 27 | 28 | & > summary { 29 | width: 200px; 30 | height: 200px; 31 | font-size: 0; /* hide the arrow */ 32 | } 33 | 34 | .inner-spacer { 35 | height: 1px; 36 | } 37 | }''' 38 | sends = [2] 39 | 40 | [[modules]] 41 | plugin = 'transform.style-inliner' 42 | data.mode = 'attr' 43 | sends = ['output'] 44 | 45 | [[modules]] 46 | plugin = 'transform.to-data-url' 47 | data.mime = 'image/svg+xml' 48 | namedSends = { '1' = ['svg'] } 49 | 50 | [[modules]] 51 | plugin = 'source.text' 52 | data.contents = ''' 53 | 57 | 58 |
59 |
60 |
61 | click me… 62 |
63 |
64 | click me to go back 65 |
66 |
67 |
68 |
''' 69 | data.language = 'html' 70 | sends = [6] 71 | 72 | [[modules]] 73 | plugin = 'source.lesscss' 74 | data.contents = ''' 75 | /* 76 | in several browsers, animation does not work inside 77 | svgs loaded as an image unless you have something 78 | animating constantly. 79 | if you don't need animation, you can remove this to 80 | save resources! 81 | */ 82 | .animation-fix { 83 | position: fixed; 84 | top: 0; 85 | left: 0; 86 | width: 1px; 87 | height: 1px; 88 | animation: animation-fix 1s infinite; 89 | } 90 | @keyframes animation-fix { 91 | 100% { transform: rotate(360deg) } 92 | } 93 | 94 | .container { 95 | font-family: sans-serif; 96 | } 97 | 98 | .click-prompt { 99 | position: absolute; 100 | width: 200px; 101 | height: 200px; 102 | padding: 1em; 103 | box-sizing: border-box; 104 | background: gray; 105 | color: white; 106 | transition: all 1s; 107 | } 108 | .contents { 109 | position: relative; 110 | width: 200px; 111 | height: 200px; 112 | padding: 1em; 113 | box-sizing: border-box; 114 | background: #4a0; 115 | color: white; 116 | transition: all 1s; 117 | transform: translateY(100%); 118 | } 119 | 120 | /* 121 | here we define what happens when the container is clicked, 122 | i.e. when it's taller than 200px 123 | */ 124 | @media (min-height: 201px) { 125 | .click-prompt { 126 | transform: scale(0); 127 | } 128 | .contents { 129 | transform: none; 130 | } 131 | }''' 132 | sends = [6] 133 | 134 | [[modules]] 135 | plugin = 'transform.style-inliner' 136 | data.mode = 'element' 137 | sends = [3] 138 | -------------------------------------------------------------------------------- /assets/no-data.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1715447595, 24 | "narHash": "sha256-VsVAUQOj/cS1LCOmMjAGeRksXIAdPnFIjCQ0XLkCsT0=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "062ca2a9370a27a35c524dc82d540e6e9824b652", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "prechoster"; 3 | inputs = { 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | }; 7 | 8 | outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let 9 | 10 | pkgs = nixpkgs.legacyPackages.${system}; 11 | 12 | compatibleSiteRev = "9ceeebd8"; 13 | staticUrl = "https://cloudwithlightning.net/random/chostin/static.${compatibleSiteRev}/"; 14 | 15 | in 16 | { 17 | 18 | packages.default = pkgs.buildNpmPackage { 19 | name = "prechoster"; 20 | src = ./.; 21 | nativeBuildInputs = with pkgs; [ git ]; 22 | npmDepsHash = "sha256-hw2QcOcKaTJ42TDoThHpvPRVQ8SgGPAQ6rz+UcQ9z8s="; 23 | 24 | installPhase = '' 25 | runHook preInstall 26 | mkdir $out 27 | cp -r dist/* $out 28 | runHook postInstall 29 | ''; 30 | 31 | PRECHOSTER_STATIC = staticUrl; 32 | PRECHOSTER_GIT_COMMIT_HASH = if (self ? rev) then self.rev else "dirty"; 33 | }; 34 | 35 | devShells.default = pkgs.mkShell { 36 | buildInputs = with pkgs; [ nodejs ]; 37 | 38 | PRECHOSTER_STATIC = staticUrl; 39 | }; 40 | 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | prechoster 8 | 9 | 10 |
11 |

Application not loaded

12 |

13 | If you can see this, the application Javascript was not executed. This may have 14 | various causes: 15 |

16 | 30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prechoster", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "fmt": "prettier --write .", 8 | "dev": "vite dev", 9 | "build": "vite build" 10 | }, 11 | "homepage": "https://github.com/cpsdqs/prechoster", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-alias": "^4.0.2", 15 | "@rollup/plugin-babel": "^5.3.1", 16 | "@rollup/plugin-commonjs": "^22.0.1", 17 | "@rollup/plugin-html": "^1.0.1", 18 | "@rollup/plugin-json": "^4.1.0", 19 | "@rollup/plugin-node-resolve": "^13.3.0", 20 | "@rollup/plugin-typescript": "^9.0.2", 21 | "@surma/rollup-plugin-off-main-thread": "^2.2.3", 22 | "@types/css-tree": "^1.0.7", 23 | "@types/react": "^18.0.25", 24 | "@types/react-dom": "^18.0.9", 25 | "autoprefixer": "^10.4.14", 26 | "postcss": "^8.4.19", 27 | "postcss-nesting": "^10.2.0", 28 | "prettier": "^2.7.1", 29 | "rollup-plugin-postcss": "^4.0.2", 30 | "rollup-plugin-terser": "^7.0.2", 31 | "typescript": "^4.7.4", 32 | "vite": "^4.5.1" 33 | }, 34 | "dependencies": { 35 | "@bramus/specificity": "^2.1.0", 36 | "@codemirror/lang-css": "^6.0.0", 37 | "@codemirror/lang-html": "^6.1.0", 38 | "@codemirror/lang-javascript": "^6.0.2", 39 | "@codemirror/lang-less": "^6.0.1", 40 | "@codemirror/lang-sass": "^6.0.2", 41 | "@codemirror/view": "^6.1.0", 42 | "@ltd/j-toml": "^1.38.0", 43 | "@uiw/codemirror-theme-xcode": "^4.11.4", 44 | "@uiw/react-codemirror": "^4.11.4", 45 | "@vitejs/plugin-react": "^4.0.3", 46 | "base64-js": "^1.5.1", 47 | "css-tree": "^2.1.0", 48 | "events": "^3.3.0", 49 | "idb": "^7.1.1", 50 | "less": "^4.1.3", 51 | "react": "^18.2.0", 52 | "react-dom": "^18.2.0", 53 | "reactflow": "^11.2.0", 54 | "rehype-stringify": "^9.0.3", 55 | "remark-breaks": "^3.0.3", 56 | "remark-gfm": "^3.0.1", 57 | "remark-parse": "^10.0.2", 58 | "remark-rehype": "^10.1.0", 59 | "rollup": "^2.77.0", 60 | "sass": "^1.63.6", 61 | "svelte": "^3.55.1", 62 | "svgo": "^2.8.0", 63 | "unified": "^10.1.2", 64 | "whatwg-url": "^11.0.0" 65 | }, 66 | "postcss": { 67 | "plugins": { 68 | "postcss-nesting": {}, 69 | "autoprefixer": {} 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | tabWidth: 4, 4 | singleQuote: true, 5 | }; 6 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import { initStorage, MemoryStorage } from './storage'; 3 | import ApplicationFrame from './ui'; 4 | 5 | let canInit = true; 6 | { 7 | // check support for 8 | if (!HTMLDialogElement.prototype.showModal) canInit = false; 9 | } 10 | 11 | if (canInit) { 12 | document.querySelector('#script-not-executed')?.remove(); 13 | 14 | const container = document.createElement('div'); 15 | container.id = 'prechoster-root'; 16 | document.body.appendChild(container); 17 | const reactRoot = createRoot(container); 18 | 19 | initStorage() 20 | .then((storage) => { 21 | reactRoot.render(); 22 | }) 23 | .catch((err) => { 24 | console.error(err); 25 | 26 | const storage = new MemoryStorage(); 27 | reactRoot.render( 28 | 29 | ); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import { ModulePlugin } from '../document'; 2 | 3 | function lazy(callback: () => Promise<{ default: T }>): () => Promise { 4 | let cached: T | null = null; 5 | return async function lazy() { 6 | if (!cached) { 7 | cached = (await callback()).default; 8 | } 9 | return cached; 10 | }; 11 | } 12 | 13 | export type ModuleDef = { 14 | title: string; 15 | description: string; 16 | load: () => Promise>; 17 | }; 18 | 19 | export const MODULES: { [k: string]: ModuleDef } = { 20 | 'source.text': { 21 | title: 'Text', 22 | description: 'Text source (e.g. HTML or CSS).', 23 | load: lazy(() => import('./source/text')), 24 | }, 25 | 'source.lesscss': { 26 | title: 'LessCSS', 27 | description: 'LessCSS source. Outputs compiled CSS.', 28 | load: lazy(() => import('./source/lesscss')), 29 | }, 30 | 'source.sass': { 31 | title: 'Sass', 32 | description: 33 | 'Sass source and module context. Outputs compiled CSS. Accepts Sass modules and text data. To use text data: @use "./"; and .$value', 34 | load: lazy(() => import('./source/sass')), 35 | }, 36 | 'source.sass-module': { 37 | title: 'Sass Module', 38 | description: 39 | 'A Sass module. Provide this to a Sass context and do @use "./.scss" or @use "./.sass", depending on mode.', 40 | load: lazy(() => import('./source/sass').then((r) => ({ default: r.sassModule }))), 41 | }, 42 | 'source.svelte': { 43 | title: 'Svelte', 44 | description: 45 | 'Svelte source and module context. Outputs compiled HTML. Data provided to this module will be available to import as "./" from any other module.', 46 | load: lazy(() => import('./source/svelte')), 47 | }, 48 | 'source.svelte-component': { 49 | title: 'Svelte Component', 50 | description: 51 | 'Svelte component source. Outputs a Svelte component you can send to a Svelte context, and then import as "./.svelte".', 52 | load: lazy(() => import('./source/svelte-component')), 53 | }, 54 | 'source.file-data': { 55 | title: 'File Data', 56 | description: 'Outputs a file as a raw data blob or UTF-8 text data.', 57 | load: lazy(() => import('./source/file-data')), 58 | }, 59 | 'source.file-data-url': { 60 | title: 'File as Data URL', 61 | description: 'Outputs a file as a `data:` URL (plain text data).', 62 | load: lazy(() => import('./source/file-data-url')), 63 | }, 64 | 'source.external-url-data': { 65 | title: 'Load from URL', 66 | description: 67 | 'Load a script or stylesheet from an external URL. You can then send e.g. scripts to Svelte and stylesheets to Sass.', 68 | load: lazy(() => import('./source/external-url')), 69 | }, 70 | 'transform.style-inliner': { 71 | title: 'Style Inliner', 72 | description: 'Given HTML and CSS input, inlines the CSS into the HTML.', 73 | load: lazy(() => import('./transform/style-inliner')), 74 | }, 75 | 'transform.svg-to-background': { 76 | title: 'SVG to backgrounds', 77 | description: 78 | 'Given HTML input, converts SVG elements tagged with `data-background` to background images on their parent element.', 79 | load: lazy(() => import('./transform/svg-to-background')), 80 | }, 81 | 'transform.svgo': { 82 | title: 'SVG Optimizer', 83 | description: 'Given text input, applies SVGO optimizations and outputs the result.', 84 | load: lazy(() => import('./transform/svgo')), 85 | }, 86 | 'transform.to-data-url': { 87 | title: 'To data URL', 88 | description: 'Converts input to a `data:` URL with a MIME type.', 89 | load: lazy(() => import('./transform/to-data-url')), 90 | }, 91 | 'transform.to-blob': { 92 | title: 'To blob', 93 | description: 94 | 'Converts input to a `blob:` URL with a MIME type. Use this if you intend to upload the contents as an external resource.', 95 | load: lazy(() => import('./transform/to-blob')), 96 | }, 97 | }; 98 | -------------------------------------------------------------------------------- /src/plugins/source/external-url.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CssData, 3 | JavascriptData, 4 | ModulePlugin, 5 | ModulePluginProps, 6 | PlainTextData, 7 | } from '../../document'; 8 | import { useMemo } from 'react'; 9 | import { SassModuleData } from './sass'; 10 | import { TextField } from '../../uikit/text-field'; 11 | import { Form, FormItem } from '../../uikit/form'; 12 | 13 | export type ExternalUrlPluginData = { 14 | url: string; 15 | type: 'javascript' | 'css' | 'scss'; 16 | }; 17 | 18 | function ExternalUrlEditor({ data, onChange }: ModulePluginProps) { 19 | const urlInput = useMemo(() => Math.random().toString(36), []); 20 | const typeSelect = useMemo(() => Math.random().toString(36), []); 21 | 22 | return ( 23 |
24 | 25 | { 29 | onChange({ ...data, url }); 30 | }} 31 | /> 32 | 33 | 34 | 45 | 46 |
47 | ); 48 | } 49 | 50 | const cache = new WeakMap(); 51 | async function fetchCached(data: ExternalUrlPluginData): Promise { 52 | if (cache.has(data)) { 53 | return cache.get(data)!; 54 | } 55 | 56 | const res = await fetch(data.url); 57 | if (!res.ok) { 58 | throw new Error(`Error loading ${data.url}: ${res.statusText}\n${await res.text()}`); 59 | } 60 | const result = await res.text(); 61 | cache.set(data, result); 62 | return result; 63 | } 64 | 65 | export default { 66 | id: 'source.external-url-data', 67 | acceptsInputs: false, 68 | acceptsNamedInputs: false, 69 | component: ExternalUrlEditor as unknown, // typescript cant figure it out 70 | initialData(): ExternalUrlPluginData { 71 | return { url: '', type: 'javascript' }; 72 | }, 73 | description(data: ExternalUrlPluginData) { 74 | if (data.type === 'javascript') return 'Load Javascript from URL'; 75 | if (data.type === 'css') return 'Load CSS from URL'; 76 | if (data.type === 'scss') return 'Load SCSS Module from URL'; 77 | return 'Load from external URL'; 78 | }, 79 | async eval(data: ExternalUrlPluginData) { 80 | const result = await fetchCached(data); 81 | 82 | switch (data.type) { 83 | case 'javascript': 84 | return new JavascriptData(result); 85 | case 'css': 86 | return new CssData(result); 87 | case 'scss': 88 | return new SassModuleData(result, 'scss'); 89 | default: 90 | return new PlainTextData(result); 91 | } 92 | }, 93 | } as ModulePlugin; 94 | -------------------------------------------------------------------------------- /src/plugins/source/file-data-url.tsx: -------------------------------------------------------------------------------- 1 | import { createRef, PureComponent } from 'react'; 2 | import { ModulePlugin, ModulePluginProps, PlainTextData } from '../../document'; 3 | import './file-data.less'; 4 | 5 | export type FileDataUrlPluginData = { 6 | url: string; 7 | }; 8 | 9 | class FileDataUrlEditor extends PureComponent> { 10 | fileInput = createRef(); 11 | 12 | onFile = () => { 13 | const fileInput = this.fileInput.current! as HTMLInputElement; 14 | if (fileInput.files!.length) { 15 | const fileReader = new FileReader(); 16 | fileReader.onload = () => { 17 | this.props.onChange({ url: fileReader.result as string }); 18 | }; 19 | fileReader.readAsDataURL(fileInput.files![0]); 20 | } 21 | }; 22 | 23 | render() { 24 | const { data, onChange } = this.props; 25 | const typeMatch = data.url.match(/^data:(.+?);/); 26 | const type = typeMatch ? typeMatch[1] : ''; 27 | let preview = null; 28 | 29 | if (type.startsWith('text/')) { 30 | const contents = atob(data.url.split(',')[1]); 31 | if (contents) { 32 | preview = ; 33 | } else { 34 | preview = ; 35 | } 36 | } else if (type.startsWith('image/')) { 37 | preview = ; 38 | } 39 | 40 | if (!preview) { 41 | preview = `(no preview for ${type})`; 42 | } 43 | 44 | return ( 45 |
46 | 47 |
{preview}
48 |
49 | ); 50 | } 51 | } 52 | 53 | export default { 54 | id: 'source.file-data-url', 55 | acceptsInputs: false, 56 | acceptsNamedInputs: false, 57 | component: FileDataUrlEditor as unknown, // typescript cant figure it out 58 | initialData(): FileDataUrlPluginData { 59 | return { url: 'data:text/plain;base64,' }; 60 | }, 61 | description() { 62 | return 'File as data URL'; 63 | }, 64 | async eval(data: FileDataUrlPluginData) { 65 | return new PlainTextData(data.url); 66 | }, 67 | } as ModulePlugin; 68 | -------------------------------------------------------------------------------- /src/plugins/source/file-data.less: -------------------------------------------------------------------------------- 1 | .plugin-file-data-editor { 2 | .file-preview { 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | user-select: text; 7 | 8 | textarea { 9 | width: 100%; 10 | box-sizing: border-box; 11 | resize: vertical; 12 | } 13 | 14 | img { 15 | max-width: 100%; 16 | max-height: 50vh; 17 | 18 | @media (min-height: 400px) { 19 | max-height: 200px; 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/source/file-data.tsx: -------------------------------------------------------------------------------- 1 | import { createRef, PureComponent } from 'react'; 2 | import { ModulePlugin, ModulePluginProps, PlainTextData, BlobData } from '../../document'; 3 | import base64js from 'base64-js'; 4 | import './file-data.less'; 5 | 6 | export type FileDataPluginData = { 7 | dataBase64: string; 8 | mime: string; 9 | outputMode: 'blob' | 'text'; 10 | }; 11 | 12 | class FileDataEditor extends PureComponent> { 13 | fileInput = createRef(); 14 | 15 | onFile = () => { 16 | const fileInput = this.fileInput.current! as HTMLInputElement; 17 | if (fileInput.files!.length) { 18 | const file = fileInput.files![0]; 19 | 20 | const fileReader = new FileReader(); 21 | fileReader.onload = () => { 22 | this.props.onChange({ 23 | ...this.props.data, 24 | dataBase64: base64js.fromByteArray( 25 | new Uint8Array(fileReader.result as ArrayBuffer) 26 | ), 27 | mime: file.type, 28 | }); 29 | }; 30 | fileReader.readAsArrayBuffer(file); 31 | } 32 | }; 33 | 34 | render() { 35 | const { data, onChange } = this.props; 36 | const type = data.mime; 37 | let preview = null; 38 | 39 | if (type.startsWith('text/')) { 40 | const buf = base64js.toByteArray(data.dataBase64); 41 | const contents = new TextDecoder().decode(buf); 42 | preview = ; 43 | } else if (type.startsWith('image/')) { 44 | const dataUrl = `data:${type};base64,${data.dataBase64}`; 45 | 46 | preview = ; 47 | } 48 | 49 | if (data.dataBase64.length && !preview) { 50 | preview = `(no preview for ${type})`; 51 | } 52 | 53 | const outputId = Math.random().toString(36); 54 | 55 | return ( 56 |
57 | 58 |
{preview}
59 |
60 | {' '} 61 | 73 |
74 |
75 | ); 76 | } 77 | } 78 | 79 | export default { 80 | id: 'source.file-data', 81 | acceptsInputs: false, 82 | acceptsNamedInputs: false, 83 | component: FileDataEditor as unknown, // typescript cant figure it out 84 | initialData(): FileDataPluginData { 85 | return { dataBase64: '', mime: 'application/octet-stream', outputMode: 'blob' }; 86 | }, 87 | description() { 88 | return 'File data'; 89 | }, 90 | async eval(data: FileDataPluginData) { 91 | if (data.outputMode === 'text') { 92 | return new PlainTextData( 93 | new TextDecoder().decode(base64js.toByteArray(data.dataBase64)) 94 | ); 95 | } 96 | return new BlobData(base64js.toByteArray(data.dataBase64), data.mime); 97 | }, 98 | } as ModulePlugin; 99 | -------------------------------------------------------------------------------- /src/plugins/source/lesscss.css: -------------------------------------------------------------------------------- 1 | .plugin-less-editor { 2 | .less-variables { 3 | .less-variable { 4 | font-family: var(--P-monospace); 5 | font-size: smaller; 6 | padding: 0.1em 0.2em; 7 | background: rgba(0, 0, 0, 0.1); 8 | border-radius: 0.2em; 9 | margin: 0.1em; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/plugins/source/lesscss.tsx: -------------------------------------------------------------------------------- 1 | import { PureComponent } from 'react'; 2 | import { 3 | ModulePlugin, 4 | ModulePluginProps, 5 | Data, 6 | NamedInputData, 7 | PlainTextData, 8 | CssData, 9 | } from '../../document'; 10 | import { CodeEditor } from '../../ui/components/code-editor'; 11 | import { less as cmLess } from '@codemirror/lang-less'; 12 | import './lesscss.css'; 13 | 14 | // @ts-ignore 15 | import Less from 'less/lib/less'; 16 | // @ts-ignore 17 | import apl from 'less/lib/less/environment/abstract-plugin-loader'; 18 | 19 | // I have no idea what's going on here. sometimes rollup resolves it to default and sometimes it doesn’t 20 | const AbstractPluginLoader = apl.default || apl; 21 | 22 | const less = Less(); 23 | 24 | less.PluginLoader = function PluginLoader(less: any) { 25 | this.less = less; 26 | }; 27 | less.PluginLoader.prototype = Object.assign(new AbstractPluginLoader(), { 28 | loadPlugin() { 29 | return Promise.reject('cannot load plugins!!'); 30 | }, 31 | }); 32 | 33 | export type LessPluginData = { 34 | contents: string; 35 | }; 36 | 37 | class LessEditor extends PureComponent> { 38 | extensions = [cmLess()]; 39 | 40 | render() { 41 | const { data, namedInputKeys, onChange } = this.props; 42 | return ( 43 |
44 | {namedInputKeys.size ? ( 45 |
46 | 47 | {[...namedInputKeys].map((key) => ( 48 | 49 | @{key} 50 | 51 | ))} 52 |
53 | ) : null} 54 | onChange({ ...data, contents })} 57 | extensions={this.extensions} 58 | /> 59 |
60 | ); 61 | } 62 | } 63 | 64 | export default { 65 | id: 'source.lesscss', 66 | acceptsInputs: false, 67 | acceptsNamedInputs: true, 68 | component: LessEditor as unknown, // typescript cant figure it out 69 | initialData(): LessPluginData { 70 | return { contents: '' }; 71 | }, 72 | description() { 73 | return 'LessCSS'; 74 | }, 75 | async eval(data: LessPluginData, inputs: Data[], namedInputs: NamedInputData) { 76 | const variables: { [k: string]: any } = {}; 77 | for (const [k, v] of namedInputs) { 78 | const plain = v.into(PlainTextData); 79 | if (!plain) { 80 | throw new Error(`could not convert named input “${k}” to plain text`); 81 | } 82 | variables[k] = { 83 | eval: () => ({ 84 | value: plain.contents.trim(), 85 | genCSS: (context: any, output: any) => output.add(plain.contents.trim()), 86 | toCSS: () => plain.contents.trim(), 87 | }), 88 | }; 89 | } 90 | 91 | const result = await less.render(data.contents, { variables }); 92 | return new CssData(result.css); 93 | }, 94 | } as ModulePlugin; 95 | -------------------------------------------------------------------------------- /src/plugins/source/svelte-component.tsx: -------------------------------------------------------------------------------- 1 | import { PureComponent } from 'react'; 2 | import { 3 | ModulePlugin, 4 | ModulePluginProps, 5 | Data, 6 | NamedInputData, 7 | PlainTextData, 8 | } from '../../document'; 9 | import { CodeEditor } from '../../ui/components/code-editor'; 10 | import { EditorView } from '@codemirror/view'; 11 | import { html } from '@codemirror/lang-html'; 12 | 13 | export class SvelteComponentData extends PlainTextData { 14 | typeId = 'svelte-component'; 15 | name: string; 16 | 17 | constructor(name: string, contents: string) { 18 | super(contents); 19 | this.name = name; 20 | } 21 | 22 | typeDescription() { 23 | return 'Svelte'; 24 | } 25 | } 26 | 27 | export type SvelteComponentPluginData = { 28 | name: string; 29 | contents: string; 30 | }; 31 | 32 | class SvelteComponentEditor extends PureComponent> { 33 | extensions = [html(), EditorView.lineWrapping]; 34 | 35 | render() { 36 | const { data, onChange } = this.props; 37 | return ( 38 |
39 | {' '} 40 | 43 | onChange({ ...data, name: (e.target as HTMLInputElement).value }) 44 | } 45 | /> 46 | onChange({ ...data, contents })} 49 | extensions={this.extensions} 50 | /> 51 |
52 | ); 53 | } 54 | } 55 | 56 | export default { 57 | id: 'source.svelte-component', 58 | acceptsInputs: false, 59 | acceptsNamedInputs: false, 60 | component: SvelteComponentEditor as unknown, // typescript cant figure it out 61 | initialData(): SvelteComponentPluginData { 62 | return { name: 'Component', contents: '' }; 63 | }, 64 | description(data: SvelteComponentPluginData) { 65 | if (data.name) return `${data.name} (Svelte Component)`; 66 | return 'Svelte Component'; 67 | }, 68 | async eval(data: SvelteComponentPluginData, inputs: Data[], namedInputs: NamedInputData) { 69 | return new SvelteComponentData(data.name, data.contents); 70 | }, 71 | } as ModulePlugin; 72 | -------------------------------------------------------------------------------- /src/plugins/source/svelte-worker.js: -------------------------------------------------------------------------------- 1 | import { compile } from 'svelte/compiler'; 2 | import { rollup } from 'rollup/dist/es/rollup.browser.js'; 3 | import { URL } from 'whatwg-url'; // chrome’s built-in URL seems to not be spec-compliant 4 | import sSvelte from 'string-node-modules:svelte/index.mjs'; 5 | import sSvelteInternal from 'string-node-modules:svelte/internal/index.mjs'; 6 | 7 | const libraryModules = { 8 | 'lib:///svelte/index.mjs': sSvelte, 9 | 'lib:///svelte/internal/index.mjs': sSvelteInternal, 10 | }; 11 | 12 | async function bundleModules(modules, main, mainId) { 13 | const index = { ...libraryModules }; 14 | for (const [k, module] of modules) { 15 | index[`file:///${k}`] = module.contents; 16 | } 17 | 18 | const bundle = await rollup({ 19 | input: `file:///${main}`, 20 | plugins: [ 21 | { 22 | resolveId(id, importer) { 23 | let url; 24 | 25 | if (!id.startsWith('.')) { 26 | // id doesn't start with ./ or ../ - library path 27 | url = new URL(id, 'lib:///'); 28 | } else { 29 | url = new URL(id, importer); 30 | } 31 | 32 | const candidates = [url.href, url.href + '/index.js', url.href + '/index.mjs']; 33 | 34 | for (const candidate of candidates) { 35 | if (candidate in index) { 36 | return candidate; 37 | } 38 | } 39 | 40 | throw new Error(`Could not resolve ${id} (in ${importer})`); 41 | }, 42 | load(id) { 43 | if (id in index) { 44 | return index[id]; 45 | } else { 46 | throw new Error(`cannot load module ${id}`); 47 | } 48 | }, 49 | transform(code, id) { 50 | if (id.endsWith('.svelte')) { 51 | const compiled = compile(code, { 52 | dev: true, 53 | filename: id.split('/').pop(), 54 | }); 55 | return compiled.js; 56 | } 57 | return null; 58 | }, 59 | }, 60 | ], 61 | }); 62 | 63 | const generated = await bundle.generate({ 64 | format: 'iife', 65 | name: mainId, 66 | exports: 'named', 67 | }); 68 | return generated.output[0].code; 69 | } 70 | 71 | addEventListener('message', (e) => { 72 | if (e.data.type === 'bundle') { 73 | bundleModules(e.data.modules, e.data.main, e.data.mainId) 74 | .then((result) => { 75 | postMessage({ id: e.data.id, success: true, result }); 76 | }) 77 | .catch((error) => { 78 | console.error(error); 79 | postMessage({ id: e.data.id, success: false, error: error.toString() }); 80 | }); 81 | } 82 | }); 83 | -------------------------------------------------------------------------------- /src/plugins/source/text.css: -------------------------------------------------------------------------------- 1 | .plugin-text-rich-text-editor { 2 | contain: layout; 3 | border: 1px solid var(--P-separator); 4 | background: rgb(var(--P-card-bg)); 5 | border-radius: 0.5em; 6 | padding: 0.5em; 7 | position: relative; 8 | overflow: hidden scroll; 9 | min-height: 100px; 10 | height: 300px; 11 | resize: vertical; 12 | 13 | @media (max-height: 600px) { 14 | height: 50vh; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/plugins/source/text.tsx: -------------------------------------------------------------------------------- 1 | import { createRef, PureComponent } from 'react'; 2 | import { 3 | ModulePlugin, 4 | ModulePluginProps, 5 | HtmlData, 6 | CssData, 7 | JavascriptData, 8 | PlainTextData, 9 | } from '../../document'; 10 | import { CodeEditor } from '../../ui/components/code-editor'; 11 | import { EditorView } from '@codemirror/view'; 12 | import { html } from '@codemirror/lang-html'; 13 | import { css } from '@codemirror/lang-css'; 14 | import { javascript } from '@codemirror/lang-javascript'; 15 | import './text.css'; 16 | 17 | const HTML_CONTENTEDITABLE = 'html-contenteditable'; 18 | 19 | const LANGUAGES: { [k: string]: () => unknown[] } = { 20 | text: () => [EditorView.lineWrapping], 21 | html: () => [html(), EditorView.lineWrapping], 22 | css: () => [css()], 23 | javascript: () => [javascript()], 24 | [HTML_CONTENTEDITABLE]: () => [html(), EditorView.lineWrapping], 25 | }; 26 | 27 | const LANGUAGE_LABELS: { [k: string]: string } = { 28 | text: 'Plain Text', 29 | html: 'HTML', 30 | css: 'CSS', 31 | javascript: 'Javascript', 32 | [HTML_CONTENTEDITABLE]: 'Rich Text (HTML)', 33 | }; 34 | 35 | export type TextPluginData = { 36 | contents: string; 37 | language: string; 38 | }; 39 | 40 | class TextEditor extends PureComponent> { 41 | state = { 42 | editingRichText: true, 43 | }; 44 | 45 | memoizedExtensions: any = null; 46 | modeSelectId = Math.random().toString(36); 47 | 48 | get extensions() { 49 | if (!this.memoizedExtensions) { 50 | this.memoizedExtensions = LANGUAGES[this.props.data.language](); 51 | } 52 | return this.memoizedExtensions; 53 | } 54 | 55 | render() { 56 | const { data, onChange } = this.props; 57 | const useRichTextCheckboxId = Math.random().toString(36); 58 | 59 | const footer = ( 60 |
61 | 62 | 63 | 77 | 78 | {data.language === HTML_CONTENTEDITABLE ? ( 79 | 80 | {' '} 81 | { 86 | this.setState({ 87 | editingRichText: (e.target as HTMLInputElement).checked, 88 | }); 89 | }} 90 | /> 91 | 92 | 93 | ) : null} 94 |
95 | ); 96 | 97 | let editor; 98 | if (data.language === HTML_CONTENTEDITABLE && this.state.editingRichText) { 99 | editor = ( 100 |
101 | onChange({ ...data, contents })} 104 | /> 105 | {footer} 106 |
107 | ); 108 | } else { 109 | editor = ( 110 | onChange({ ...data, contents })} 113 | extensions={this.extensions} 114 | footer={footer} 115 | /> 116 | ); 117 | } 118 | 119 | return
{editor}
; 120 | } 121 | } 122 | 123 | function sanitizeHtmlALittleBit(html: string) { 124 | const doc = new DOMParser().parseFromString( 125 | `` + html, 126 | 'text/html' 127 | ); 128 | for (const s of doc.querySelectorAll('script, style')) s.remove(); 129 | return doc.body.innerHTML; 130 | } 131 | 132 | class RichTextEditor extends PureComponent { 133 | node = createRef(); 134 | currentHtmlValue = ''; 135 | 136 | componentDidMount() { 137 | this.currentHtmlValue = this.props.html; 138 | this.node.current!.innerHTML = sanitizeHtmlALittleBit(this.props.html); 139 | this.node.current!.addEventListener('input', this.contentsDidChange); 140 | } 141 | 142 | componentDidUpdate(prevProps: RichTextEditor.Props) { 143 | if (prevProps.html !== this.props.html) { 144 | if (this.props.html !== this.currentHtmlValue) { 145 | this.setHtml(this.props.html); 146 | } 147 | } 148 | } 149 | 150 | contentsDidChange = () => { 151 | this.currentHtmlValue = this.node.current!.innerHTML; 152 | this.props.onHtmlChange(this.currentHtmlValue); 153 | }; 154 | 155 | setHtml(html: string) { 156 | this.node.current!.innerHTML = sanitizeHtmlALittleBit(html); 157 | this.currentHtmlValue = html; 158 | } 159 | 160 | render() { 161 | return ( 162 |
167 | ); 168 | } 169 | } 170 | namespace RichTextEditor { 171 | export interface Props { 172 | html: string; 173 | onHtmlChange: (s: string) => void; 174 | } 175 | } 176 | 177 | export default { 178 | id: 'source.text', 179 | acceptsInputs: false, 180 | acceptsNamedInputs: false, 181 | component: TextEditor as unknown, // typescript cant figure it out 182 | initialData(): TextPluginData { 183 | return { contents: '', language: 'text' }; 184 | }, 185 | description(data: TextPluginData) { 186 | if (data.language === 'html') return 'HTML'; 187 | else if (data.language === 'css') return 'CSS'; 188 | else if (data.language === 'javascript') return 'Javascript'; 189 | else if (data.language === HTML_CONTENTEDITABLE) return 'HTML (Rich Text)'; 190 | return 'Plain Text Data'; 191 | }, 192 | async eval(data: TextPluginData) { 193 | if (data.language === 'html' || data.language === HTML_CONTENTEDITABLE) 194 | return new HtmlData(data.contents); 195 | else if (data.language === 'css') return new CssData(data.contents); 196 | else if (data.language === 'javascript') return new JavascriptData(data.contents); 197 | return new PlainTextData(data.contents); 198 | }, 199 | } as ModulePlugin; 200 | -------------------------------------------------------------------------------- /src/plugins/transform/svg-to-background.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ModulePlugin, 3 | ModulePluginProps, 4 | HtmlData, 5 | Data, 6 | EvalOptions, 7 | NamedInputData, 8 | } from '../../document'; 9 | // @ts-ignore 10 | import { optimize } from 'svgo/dist/svgo.browser'; 11 | import Checkbox from '../../uikit/checkbox'; 12 | import { Form, FormItem } from '../../uikit/form'; 13 | import { ModuleStatus } from '../../ui/components/module-status'; 14 | 15 | export type SvgToBackgroundData = { 16 | useSvgo: boolean; 17 | }; 18 | 19 | function SvgToBackground({ data, onChange, userData }: ModulePluginProps) { 20 | const useSvgoId = Math.random().toString(36); 21 | 22 | return ( 23 |
24 | 29 | onChange({ ...data, useSvgo })} 33 | /> 34 | 35 | 36 | {typeof userData.svgCount === 'number' ? ( 37 | <> 38 | converted {userData.svgCount.toString()} <svg> element 39 | {userData.svgCount === 1 ? '' : 's'} 40 | 41 | ) : null} 42 | 43 |
44 | ); 45 | } 46 | 47 | export default { 48 | id: 'transform.svg-to-background', 49 | acceptsInputs: true, 50 | acceptsNamedInputs: false, 51 | component: SvgToBackground as unknown, 52 | initialData() { 53 | return { useSvgo: true }; 54 | }, 55 | description() { 56 | return 'SVG to backgrounds'; 57 | }, 58 | async eval( 59 | data: SvgToBackgroundData, 60 | inputs: Data[], 61 | _: NamedInputData, 62 | { userData }: EvalOptions 63 | ) { 64 | let htmlInput = ''; 65 | for (const input of inputs) { 66 | let data; 67 | if ((data = input.into(HtmlData))) { 68 | htmlInput += data.contents; 69 | } else { 70 | throw new Error('svg to background received input that is not html'); 71 | } 72 | } 73 | 74 | const htmlSource = [ 75 | '', 76 | htmlInput, 77 | '', 78 | ].join(''); 79 | const doc = new DOMParser().parseFromString(htmlSource, 'text/html'); 80 | const body = doc.body; 81 | 82 | let svgCount = 0; 83 | for (const svg of doc.querySelectorAll('svg[data-background]')) { 84 | const parent = svg.parentNode!; 85 | 86 | // remove whitespace 87 | if (svg.nextSibling?.nodeType === 3 && !svg.nextSibling?.textContent?.trim()) { 88 | svg.nextSibling.remove(); 89 | } 90 | 91 | svg.remove(); 92 | svg.removeAttribute('data-background'); 93 | if (!svg.hasAttribute('xmlns')) svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); 94 | 95 | let svgMarkup = svg.outerHTML; 96 | if (data.useSvgo) { 97 | const result = optimize(svgMarkup, { 98 | multipass: true, 99 | plugins: [{ name: 'preset-default' }], 100 | }); 101 | if (result.error) { 102 | throw new Error( 103 | `SVGO error: ${result.error}\n\n(in svg ${svgMarkup.slice(0, 50)}…)` 104 | ); 105 | } 106 | svgMarkup = result.data; 107 | } 108 | 109 | const url = 'data:image/svg+xml;base64,' + btoa(`${svgMarkup}`); 110 | (parent as HTMLElement).style.backgroundImage = `url(${url})`; 111 | svgCount++; 112 | } 113 | 114 | userData.svgCount = svgCount; 115 | 116 | return new HtmlData(body.innerHTML); 117 | }, 118 | } as ModulePlugin; 119 | -------------------------------------------------------------------------------- /src/plugins/transform/svgo.tsx: -------------------------------------------------------------------------------- 1 | import { ModulePlugin, ModulePluginProps, PlainTextData, Data } from '../../document'; 2 | // @ts-ignore 3 | import { optimize } from 'svgo/dist/svgo.browser'; 4 | 5 | export type SvgoData = {}; 6 | 7 | function Svgo({ data, onChange }: ModulePluginProps) { 8 | return null; 9 | } 10 | 11 | export default { 12 | id: 'transform.svgo', 13 | acceptsInputs: true, 14 | acceptsNamedInputs: false, 15 | component: Svgo as unknown, 16 | initialData() { 17 | return {}; 18 | }, 19 | description() { 20 | return 'SVG Optimizer'; 21 | }, 22 | async eval(data: SvgoData, inputs: Data[]) { 23 | let svgInput = ''; 24 | if (inputs.length > 1) throw new Error('cannot use SVGO with multiple inputs'); 25 | for (const input of inputs) { 26 | let data; 27 | if ((data = input.into(PlainTextData))) { 28 | svgInput += data.contents; 29 | } else { 30 | throw new Error('svg received input that is not text'); 31 | } 32 | } 33 | 34 | const result = optimize(svgInput, { 35 | multipass: true, 36 | plugins: [{ name: 'preset-default' }], 37 | }); 38 | if (result.error) { 39 | throw new Error(`SVGO error: ${result.error}\n\n(in svg ${svgInput.slice(0, 50)}…)`); 40 | } 41 | 42 | return new PlainTextData(result.data); 43 | }, 44 | } as ModulePlugin; 45 | -------------------------------------------------------------------------------- /src/plugins/transform/to-blob.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { 3 | ModulePlugin, 4 | ModulePluginProps, 5 | ByteSliceData, 6 | BlobData, 7 | PlainTextData, 8 | Data, 9 | } from '../../document'; 10 | import { Form, FormFooter, FormItem } from '../../uikit/form'; 11 | import Checkbox from '../../uikit/checkbox'; 12 | import { TextField } from '../../uikit/text-field'; 13 | import { Button } from '../../uikit/button'; 14 | 15 | export type ToBlobData = { 16 | mime: string; 17 | override?: string; 18 | useOverride: boolean; 19 | }; 20 | 21 | function ToBlob({ id, data, onChange, document }: ModulePluginProps) { 22 | const [loading, setLoading] = useState(false); 23 | 24 | const mimeId = Math.random().toString(36); 25 | const useOverrideId = Math.random().toString(36); 26 | const overrideId = Math.random().toString(36); 27 | 28 | const download = () => { 29 | setLoading(true); 30 | document.eval(id).then((result) => { 31 | setLoading(false); 32 | 33 | if (result.type === 'output') { 34 | const blobUrl = (result.outputs.get(id) as BlobData).objectUrl; 35 | const a = window.document.createElement('a'); 36 | a.href = blobUrl; 37 | a.download = ''; 38 | a.click(); 39 | 40 | result.drop(); 41 | } else if (result.type === 'error') { 42 | alert('Could not generate file\n\n' + result.error); 43 | } 44 | }); 45 | }; 46 | 47 | return ( 48 |
49 | 50 | { 56 | onChange({ ...data, mime }); 57 | }} 58 | /> 59 | 60 | 61 | 62 | 65 | 66 |
67 | 68 | { 72 | onChange({ ...data, useOverride }); 73 | }} 74 | /> 75 | 76 | {data.useOverride && ( 77 | 78 | { 84 | const newData = { ...data }; 85 | if (override) newData.override = override; 86 | else delete newData.override; 87 | onChange(newData); 88 | }} 89 | /> 90 | 91 | )} 92 |
93 | ); 94 | } 95 | 96 | export default { 97 | id: 'transform.to-blob', 98 | acceptsInputs: true, 99 | acceptsNamedInputs: false, 100 | component: ToBlob as unknown, 101 | initialData() { 102 | return { mime: 'image/svg+xml', useOverride: false }; 103 | }, 104 | description() { 105 | return 'To blob'; 106 | }, 107 | async eval(data: ToBlobData, inputs: Data[]) { 108 | if (data.useOverride && data.override) return new PlainTextData(data.override); 109 | 110 | let buffers = []; 111 | let len = 0; 112 | for (const input of inputs) { 113 | let data; 114 | if ((data = input.into(ByteSliceData))) { 115 | buffers.push(data.contents); 116 | len += data.contents.byteLength; 117 | } else { 118 | throw new Error(`don’t know how to convert ${input.typeDescription()} to a blob`); 119 | } 120 | } 121 | 122 | const buffer = new Uint8Array(len); 123 | let cursor = 0; 124 | for (const buf of buffers) { 125 | buffer.set(buf, cursor); 126 | cursor += buf.byteLength; 127 | } 128 | 129 | return new BlobData(buffer, data.mime); 130 | }, 131 | } as ModulePlugin; 132 | -------------------------------------------------------------------------------- /src/plugins/transform/to-data-url.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ModulePlugin, 3 | ModulePluginProps, 4 | ByteSliceData, 5 | PlainTextData, 6 | Data, 7 | } from '../../document'; 8 | import base64js from 'base64-js'; 9 | import { Form, FormItem } from '../../uikit/form'; 10 | import { TextField } from '../../uikit/text-field'; 11 | 12 | export type ToDataUrlData = { 13 | mime: string; 14 | }; 15 | 16 | function ToDataUrl({ data, onChange }: ModulePluginProps) { 17 | const mimeId = Math.random().toString(36); 18 | 19 | return ( 20 |
21 | 22 | { 28 | onChange({ ...data, mime }); 29 | }} 30 | /> 31 | 32 |
33 | ); 34 | } 35 | 36 | export default { 37 | id: 'transform.to-data-url', 38 | acceptsInputs: true, 39 | acceptsNamedInputs: false, 40 | component: ToDataUrl as unknown, 41 | initialData() { 42 | return { mime: 'text/plain' }; 43 | }, 44 | description() { 45 | return 'To data URL'; 46 | }, 47 | async eval(data: ToDataUrlData, inputs: Data[]) { 48 | let buffers = []; 49 | let len = 0; 50 | for (const input of inputs) { 51 | let data; 52 | if ((data = input.into(ByteSliceData))) { 53 | buffers.push(data.contents); 54 | len += data.contents.byteLength; 55 | } else { 56 | throw new Error( 57 | `don’t know how to convert ${input.typeDescription()} to a data URL` 58 | ); 59 | } 60 | } 61 | 62 | const buffer = new Uint8Array(len); 63 | let cursor = 0; 64 | for (const buf of buffers) { 65 | buffer.set(buf, cursor); 66 | cursor += buf.byteLength; 67 | } 68 | 69 | const url = `data:${data.mime};base64,${base64js.fromByteArray(buffer)}`; 70 | 71 | return new PlainTextData(url); 72 | }, 73 | } as ModulePlugin; 74 | -------------------------------------------------------------------------------- /src/storage-context.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext } from 'react'; 2 | import { IStorage } from './storage'; 3 | 4 | export const StorageContext: React.Context = createContext(null as any); 5 | -------------------------------------------------------------------------------- /src/storage/versions/index.ts: -------------------------------------------------------------------------------- 1 | import { IDBPDatabase, IDBPTransaction } from 'idb'; 2 | import { deserializeV1, serializeV1, migrateV1, SchemaV1, nextDocumentIdV1 } from './v1'; 3 | 4 | export interface Schema extends SchemaV1 {} 5 | 6 | export const deserialize = deserializeV1; 7 | export const serialize = serializeV1; 8 | export const nextDocumentId = nextDocumentIdV1; 9 | 10 | type MigrateFn = ( 11 | db: IDBPDatabase, 12 | transaction: IDBPTransaction 13 | ) => void; 14 | const MIGRATIONS: MigrateFn[] = [() => {}, migrateV1]; 15 | 16 | export async function migrate( 17 | db: IDBPDatabase, 18 | oldVersion: number, 19 | newVersion: number, 20 | transaction: IDBPTransaction 21 | ) { 22 | let version = oldVersion; 23 | while (version < newVersion) { 24 | version++; 25 | console.log('running migrations for version ' + version); 26 | await MIGRATIONS[version](db, transaction); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/storage/versions/onv1/index.ts: -------------------------------------------------------------------------------- 1 | import { parseImpl } from './parse'; 2 | import { JsonValue } from '../../../document'; 3 | import { stringifyImpl } from './stringify'; 4 | 5 | // V1 object notation 6 | 7 | export function parse(s: string): JsonValue { 8 | return parseImpl(s); 9 | } 10 | 11 | export function stringify(v: JsonValue): string { 12 | return stringifyImpl(v); 13 | } 14 | -------------------------------------------------------------------------------- /src/storage/versions/onv1/stringify.ts: -------------------------------------------------------------------------------- 1 | import { IDENT_REGEX } from './parse'; 2 | import { JsonValue } from '../../../document'; 3 | 4 | const INDENT = ' '; 5 | const STRING_INDENT = ' '; 6 | 7 | function doubleQuoteString(s: string): string { 8 | let out = '"'; 9 | for (const c of s) { 10 | if (c === '\n') out += '\\n'; 11 | else if (c === '\r') out += '\\r'; 12 | else if (c === '"') out += '\\"'; 13 | else if (c === '\\') out += '\\\\'; 14 | else out += c; 15 | } 16 | out += '"'; 17 | return out; 18 | } 19 | 20 | function stringifyMapKey(s: string): string { 21 | const m = s.match(IDENT_REGEX); 22 | if (m && m[0].length === s.length) { 23 | return s; 24 | } else { 25 | return doubleQuoteString(s); 26 | } 27 | } 28 | 29 | const MAX_MAP_SINGLE_LINE_COUNT = 5; 30 | const MAX_MAP_SINGLE_LINE_LEN = 50; 31 | const MAX_LIST_SINGLE_LINE_COUNT = 10; 32 | const MAX_LIST_SINGLE_LINE_LEN = 50; 33 | 34 | function stringifyMap(value: Record, indent: string) { 35 | const size = Object.keys(value).length; 36 | if (!size) return '{}'; 37 | 38 | singleLine: while (size <= MAX_MAP_SINGLE_LINE_COUNT) { 39 | let out = '{ '; 40 | for (const [k, v] of Object.entries(value)) { 41 | out += stringifyMapKey(k); 42 | out += ' = '; 43 | out += stringifyOne(v, indent); 44 | out += '; '; 45 | 46 | if (out.includes('\n') || out.length > MAX_MAP_SINGLE_LINE_LEN) { 47 | // nope 48 | break singleLine; 49 | } 50 | } 51 | out += '}'; 52 | return out; 53 | } 54 | 55 | let out = '{\n'; 56 | const innerIndent = indent + INDENT; 57 | for (const [k, v] of Object.entries(value)) { 58 | out += innerIndent + stringifyMapKey(k); 59 | out += ' = '; 60 | out += stringifyOne(v, innerIndent); 61 | out += ';\n'; 62 | } 63 | out += indent + '}'; 64 | return out; 65 | } 66 | 67 | function stringifyList(value: JsonValue[], indent: string) { 68 | if (!value.length) return '[]'; 69 | 70 | singleLine: while (value.length <= MAX_LIST_SINGLE_LINE_COUNT) { 71 | let out = '['; 72 | for (const item of value) { 73 | if (out.length > 1) out += ' '; 74 | out += stringifyOne(item, indent); 75 | 76 | if (out.includes('\n') || out.length > MAX_LIST_SINGLE_LINE_LEN) { 77 | // nope 78 | break singleLine; 79 | } 80 | } 81 | out += ']'; 82 | return out; 83 | } 84 | 85 | let out = '[\n'; 86 | const innerIndent = indent + INDENT; 87 | for (const item of value) { 88 | out += innerIndent; 89 | out += stringifyOne(item, innerIndent); 90 | out += '\n'; 91 | } 92 | out += indent + ']'; 93 | return out; 94 | } 95 | 96 | function stringifyString(value: string, indent: string) { 97 | if (value.includes('\n')) { 98 | const innerIndent = indent ? indent + STRING_INDENT : indent; 99 | 100 | const contents = value 101 | .split('\n') 102 | .map((line) => { 103 | // add escapes 104 | let out = ''; 105 | let rest = line; 106 | while (rest.length) { 107 | if (rest[0] === "'" && rest[1] === "'") { 108 | out += "'''"; 109 | rest += rest.substring(2); 110 | } else if (rest[0] === '$' && rest[1] === '{') { 111 | out += "''${"; 112 | rest += rest.substring(2); 113 | } else { 114 | out += rest[0]; 115 | rest = rest.substring(1); 116 | } 117 | } 118 | return out; 119 | }) 120 | .map((line) => { 121 | // trim empty lines; add indentation 122 | if (!line.trim()) return ''; 123 | return innerIndent + line; 124 | }) 125 | .join('\n'); 126 | 127 | if (contents.endsWith('\n')) { 128 | return `''\n${contents}${indent}''`; 129 | } else { 130 | return `''\n${contents}''`; 131 | } 132 | } else { 133 | return doubleQuoteString(value); 134 | } 135 | } 136 | 137 | function stringifyOne(value: JsonValue, indent: string): string { 138 | if (Array.isArray(value)) { 139 | return stringifyList(value, indent); 140 | } else if (typeof value === 'object' && value !== null) { 141 | return stringifyMap(value, indent); 142 | } else if (typeof value === 'string') { 143 | return stringifyString(value, indent); 144 | } else if (typeof value === 'boolean' || typeof value === 'number') { 145 | return value.toString(); 146 | } else if (value === null) { 147 | return 'null'; 148 | } 149 | 150 | throw new Error(`stringify not implemented for type ${typeof value}`); 151 | } 152 | 153 | export function stringifyImpl(v: JsonValue): string { 154 | return stringifyOne(v, ''); 155 | } 156 | -------------------------------------------------------------------------------- /src/storage/versions/v0.ts: -------------------------------------------------------------------------------- 1 | import { Document, SerValue, Module, UnloadedPlugin } from '../../document'; 2 | 3 | export function deserializeV0(_data: SerValue) { 4 | const data = _data as any; // just assume it's fine 5 | 6 | const doc = new Document(); 7 | const modules = data.modules.map((module: SerValue) => deserializeModule(doc, module)); 8 | doc.init({ 9 | title: '', 10 | titleInPost: false, 11 | modules, 12 | }); 13 | return doc; 14 | } 15 | function deserializeModule(document: Document, _data: SerValue) { 16 | // just assume it's fine 17 | const data = _data as any; 18 | 19 | const module = new Module(new UnloadedPlugin(data.pluginId, document), data.data); 20 | module.id = data.id; 21 | module.sends = data.sends; 22 | for (const k in data.namedSends) { 23 | module.namedSends.set(k, new Set(data.namedSends[k])); 24 | } 25 | if (data.graphPos) { 26 | module.graphPos = { x: data.graphPos[0], y: data.graphPos[1] }; 27 | } 28 | return module; 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/components/code-editor.css: -------------------------------------------------------------------------------- 1 | .code-editor { 2 | border: 1px solid var(--P-separator); 3 | border-radius: 0.5em; 4 | overflow: hidden; 5 | min-height: 100px; 6 | height: 300px; 7 | resize: vertical; 8 | display: flex; 9 | flex-direction: column; 10 | 11 | .editor-footer { 12 | flex-shrink: 0; 13 | min-height: 1em; 14 | padding: 0.2em 0.5em; 15 | } 16 | 17 | .cm-theme { 18 | height: 0; 19 | flex: 1; 20 | } 21 | 22 | .cm-editor { 23 | height: 100%; 24 | font-size: var(--P-font-size-code); 25 | } 26 | } 27 | 28 | @media (max-height: 600px) { 29 | .code-editor { 30 | height: 50vh; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/components/code-editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { createRef, PureComponent } from 'react'; 2 | import CodeMirror from './codemirror'; 3 | import { ReactCodeMirrorRef } from '@uiw/react-codemirror'; 4 | import { xcodeLight, xcodeDark } from '@uiw/codemirror-theme-xcode'; 5 | import './code-editor.css'; 6 | 7 | export class CodeEditor extends PureComponent { 8 | themeQuery = window.matchMedia('(prefers-color-scheme: light)'); 9 | editor = createRef(); 10 | 11 | componentDidMount() { 12 | this.themeQuery.addEventListener('change', this.onThemeChange); 13 | } 14 | 15 | componentWillUnmount() { 16 | this.themeQuery.removeEventListener('change', this.onThemeChange); 17 | } 18 | 19 | onThemeChange = () => { 20 | this.forceUpdate(); 21 | }; 22 | 23 | onValueChange = (newValue: string) => { 24 | if (newValue === this.props.value) return; 25 | this.props.onChange(newValue); 26 | }; 27 | 28 | render() { 29 | const { value, extensions, footer, readOnly } = this.props; 30 | const light = window.matchMedia('(prefers-color-scheme: light)').matches; 31 | const theme = light ? xcodeLight : xcodeDark; 32 | 33 | return ( 34 |
35 | 43 |
{footer}
44 |
45 | ); 46 | } 47 | } 48 | namespace CodeEditor { 49 | export interface Props { 50 | value: string; 51 | extensions: any[]; 52 | footer?: React.ReactNode; 53 | onChange: (v: string) => void; 54 | readOnly?: boolean; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ui/components/codemirror.tsx: -------------------------------------------------------------------------------- 1 | // from https://github.com/uiwjs/react-codemirror/blob/56fb55855a42888bf93d3d4dbe251cfc7c8e37ee/ 2 | // core/src/useCodeMirror.ts 3 | 4 | import { forwardRef, useRef, useEffect, useState, useImperativeHandle } from 'react'; 5 | import { EditorState, StateEffect } from '@codemirror/state'; 6 | import { indentWithTab } from '@codemirror/commands'; 7 | import { EditorView, keymap, ViewUpdate, placeholder } from '@codemirror/view'; 8 | import { basicSetup } from '@uiw/codemirror-extensions-basic-setup'; 9 | import { ReactCodeMirrorRef, ReactCodeMirrorProps } from '@uiw/react-codemirror'; 10 | 11 | export interface UseCodeMirror extends ReactCodeMirrorProps { 12 | container?: HTMLDivElement | null; 13 | } 14 | 15 | export function useCodeMirror(props: UseCodeMirror) { 16 | const { 17 | value, 18 | selection, 19 | onChange, 20 | onCreateEditor, 21 | onUpdate, 22 | extensions = [], 23 | autoFocus, 24 | theme = 'light', 25 | height = '', 26 | minHeight = '', 27 | maxHeight = '', 28 | placeholder: placeholderStr = '', 29 | width = '', 30 | minWidth = '', 31 | maxWidth = '', 32 | editable = true, 33 | readOnly = false, 34 | indentWithTab: defaultIndentWithTab = true, 35 | basicSetup: defaultBasicSetup = true, 36 | root, 37 | } = props; 38 | const [container, setContainer] = useState(); 39 | const [view, setView] = useState(); 40 | const [state, setState] = useState(); 41 | const updateListener = EditorView.updateListener.of((vu: ViewUpdate) => { 42 | if (vu.docChanged && typeof onChange === 'function') { 43 | const doc = vu.state.doc; 44 | const value = doc.toString(); 45 | onChange(value, vu); 46 | } 47 | // onStatistics && onStatistics(getStatistics(vu)); 48 | }); 49 | 50 | let getExtensions = [updateListener]; 51 | if (defaultIndentWithTab) { 52 | getExtensions.unshift(keymap.of([indentWithTab])); 53 | } 54 | if (defaultBasicSetup) { 55 | if (typeof defaultBasicSetup === 'boolean') { 56 | getExtensions.unshift(basicSetup()); 57 | } else { 58 | getExtensions.unshift(basicSetup(defaultBasicSetup)); 59 | } 60 | } 61 | 62 | if (placeholderStr) { 63 | getExtensions.unshift(placeholder(placeholderStr)); 64 | } 65 | 66 | if (typeof theme !== 'string') { 67 | getExtensions.push(theme); 68 | } 69 | 70 | if (editable === false) { 71 | getExtensions.push(EditorView.editable.of(false)); 72 | } 73 | if (readOnly) { 74 | getExtensions.push(EditorState.readOnly.of(true)); 75 | } 76 | 77 | if (onUpdate && typeof onUpdate === 'function') { 78 | getExtensions.push(EditorView.updateListener.of(onUpdate)); 79 | } 80 | getExtensions = getExtensions.concat(extensions); 81 | 82 | useEffect(() => { 83 | if (container && !state) { 84 | const stateCurrent = EditorState.create({ 85 | doc: value, 86 | selection, 87 | extensions: getExtensions, 88 | }); 89 | setState(stateCurrent); 90 | if (!view) { 91 | const viewCurrent = new EditorView({ 92 | state: stateCurrent, 93 | parent: container, 94 | root, 95 | }); 96 | setView(viewCurrent); 97 | onCreateEditor && onCreateEditor(viewCurrent, stateCurrent); 98 | } 99 | } 100 | return () => { 101 | if (view) { 102 | setState(undefined); 103 | setView(undefined); 104 | } 105 | }; 106 | }, [container, state]); 107 | 108 | useEffect(() => setContainer(props.container!), [props.container]); 109 | 110 | useEffect( 111 | () => () => { 112 | if (view) { 113 | view.destroy(); 114 | setView(undefined); 115 | } 116 | }, 117 | [view] 118 | ); 119 | 120 | useEffect(() => { 121 | if (autoFocus && view) { 122 | view.focus(); 123 | } 124 | }, [autoFocus, view]); 125 | 126 | useEffect(() => { 127 | if (view) { 128 | view.dispatch({ effects: StateEffect.reconfigure.of(getExtensions) }); 129 | } 130 | // eslint-disable-next-line react-hooks/exhaustive-deps 131 | }, [ 132 | theme, 133 | extensions, 134 | height, 135 | minHeight, 136 | maxHeight, 137 | width, 138 | minWidth, 139 | maxWidth, 140 | placeholderStr, 141 | editable, 142 | readOnly, 143 | defaultIndentWithTab, 144 | defaultBasicSetup, 145 | onChange, 146 | onUpdate, 147 | ]); 148 | 149 | // don't useEffect on this so we can avoid desync 150 | { 151 | const currentValue = view ? view.state.doc.toString() : ''; 152 | if (view && value !== currentValue) { 153 | view.dispatch({ 154 | changes: { from: 0, to: currentValue.length, insert: value || '' }, 155 | // keep selection 156 | selection: view.state.selection, 157 | }); 158 | } 159 | } 160 | 161 | return { state, setState, view, setView, container, setContainer }; 162 | } 163 | 164 | // from core/src/index.tsx 165 | export default forwardRef((props, ref) => { 166 | const { 167 | className, 168 | value = '', 169 | selection, 170 | extensions = [], 171 | onChange, 172 | onStatistics, 173 | onCreateEditor, 174 | onUpdate, 175 | autoFocus, 176 | theme, 177 | height, 178 | minHeight, 179 | maxHeight, 180 | width, 181 | minWidth, 182 | maxWidth, 183 | basicSetup, 184 | placeholder, 185 | indentWithTab, 186 | editable, 187 | readOnly, 188 | root, 189 | ...other 190 | } = props; 191 | const editor = useRef(null); 192 | const { state, view, container } = useCodeMirror({ 193 | container: editor.current, 194 | root, 195 | value, 196 | autoFocus, 197 | theme, 198 | height, 199 | minHeight, 200 | maxHeight, 201 | width, 202 | minWidth, 203 | maxWidth, 204 | basicSetup, 205 | placeholder, 206 | indentWithTab, 207 | editable, 208 | readOnly, 209 | selection, 210 | onChange, 211 | onStatistics, 212 | onCreateEditor, 213 | onUpdate, 214 | extensions, 215 | }); 216 | 217 | useImperativeHandle(ref, () => ({ editor: editor.current, state, view }), [ 218 | editor, 219 | container, 220 | state, 221 | view, 222 | ]); 223 | 224 | const defaultClassNames = typeof theme === 'string' ? `cm-theme-${theme}` : 'cm-theme'; 225 | return
; 226 | }); 227 | -------------------------------------------------------------------------------- /src/ui/components/data-preview.css: -------------------------------------------------------------------------------- 1 | .data-preview-html { 2 | background: white; 3 | color: black; 4 | color-scheme: light; 5 | border-radius: 0.5em; 6 | overflow: auto; 7 | font-size: var(--P-font-size-content); 8 | } 9 | 10 | .data-preview-text { 11 | padding: 1rem; 12 | font-family: var(--P-monospace); 13 | font-size: smaller; 14 | white-space: pre-wrap; 15 | overflow: auto; 16 | } 17 | 18 | .data-preview-unknown { 19 | padding: 0.5em 1em; 20 | text-align: center; 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/components/data-preview.tsx: -------------------------------------------------------------------------------- 1 | import { createRef, PureComponent } from 'react'; 2 | import { Data, PlainTextData, HtmlData } from '../../document'; 3 | import './data-preview.css'; 4 | 5 | export function DataPreview({ data }: { data: Data }) { 6 | if (data instanceof HtmlData) { 7 | return ; 8 | } else if (data instanceof PlainTextData) { 9 | return ; 10 | } 11 | return ; 12 | } 13 | 14 | class HtmlPreview extends PureComponent<{ html: string }> { 15 | node = createRef(); 16 | 17 | componentDidMount() { 18 | this.renderHtml(); 19 | } 20 | 21 | componentDidUpdate(prevProps: { html: string }) { 22 | if (this.props.html !== prevProps.html) { 23 | this.renderHtml(); 24 | } 25 | } 26 | 27 | renderHtml() { 28 | if (!this.node.current!.shadowRoot) { 29 | this.node.current!.attachShadow({ mode: 'open' }); 30 | } 31 | const doc = new DOMParser().parseFromString(this.props.html, 'text/html'); 32 | for (const node of doc.querySelectorAll('script, iframe')) { 33 | node.remove(); 34 | } 35 | this.node.current!.shadowRoot!.innerHTML = doc.body.innerHTML; 36 | } 37 | 38 | render() { 39 | return
; 40 | } 41 | } 42 | 43 | function TextPreview({ text }: { text: string }) { 44 | return
{text}
; 45 | } 46 | 47 | function UnknownPreview({ type }: { type: string }) { 48 | return
no preview for {type}
; 49 | } 50 | -------------------------------------------------------------------------------- /src/ui/components/module-graph/auto-layout.ts: -------------------------------------------------------------------------------- 1 | import { Document, ModuleId, AnyModule, MOD_OUTPUT } from '../../../document'; 2 | import { 3 | MOD_HEADER_HEIGHT, 4 | MOD_INPUT_HEIGHT, 5 | MOD_OUTPUT_HEIGHT, 6 | MOD_NAMED_INPUT_HEIGHT, 7 | MIN_ROW_GAP, 8 | GRID_SIZE, 9 | } from './consts'; 10 | 11 | function toposortDoc(doc: Document, backwards: boolean = false): AnyModule[] { 12 | const indexedNodes = new Map(); 13 | for (const node of doc.modules) indexedNodes.set(node.id, node); 14 | const unmarkedNodes = new Set(doc.modules); 15 | const tmpMarkedNodes = new Set(); 16 | const sorted: AnyModule[] = []; 17 | 18 | const visit = (node: AnyModule) => { 19 | if (!unmarkedNodes.has(node)) return; 20 | if (tmpMarkedNodes.has(node)) return; 21 | tmpMarkedNodes.add(node); 22 | for (const targetId of node.sends) { 23 | visit(indexedNodes.get(targetId)); 24 | } 25 | for (const targetId of node.namedSends.keys()) { 26 | visit(indexedNodes.get(targetId)); 27 | } 28 | tmpMarkedNodes.delete(node); 29 | unmarkedNodes.delete(node); 30 | sorted.unshift(node); 31 | }; 32 | 33 | while (unmarkedNodes.size) { 34 | if (backwards) { 35 | visit([...unmarkedNodes][0]); 36 | } else { 37 | visit([...unmarkedNodes].pop()!); 38 | } 39 | } 40 | 41 | return backwards ? sorted.reverse() : sorted; 42 | } 43 | 44 | export type NodeLayout = { 45 | column: number; 46 | index: number; 47 | y: number; 48 | height: number; 49 | acceptsInputs: boolean; 50 | namedInputs: Set; 51 | }; 52 | export type GraphLayout = { 53 | columns: (AnyModule | null)[][]; 54 | layouts: Map; 55 | indices: Map; 56 | }; 57 | export function layoutNodes(doc: Document): GraphLayout { 58 | const columns: (AnyModule | null)[][] = []; 59 | const nodeLayouts = new Map(); 60 | 61 | const indices = new Map(); 62 | const indexedNodes = new Map(); 63 | const outgoingEdges = new Map(); 64 | for (let i = 0; i < doc.modules.length; i++) { 65 | const node = doc.modules[i]; 66 | indices.set(node.id, i); 67 | indexedNodes.set(node.id, node); 68 | const edges = new Set([...node.sends, ...node.namedSends.keys()]); 69 | outgoingEdges.set(node, edges); 70 | } 71 | 72 | nodeLayouts.set(MOD_OUTPUT, { 73 | column: 0, 74 | index: 0, 75 | y: 0, 76 | height: 64, 77 | acceptsInputs: true, 78 | namedInputs: new Set(), 79 | }); 80 | columns.push([null]); 81 | 82 | for (const node of toposortDoc(doc, true)) { 83 | let column = 0; 84 | for (const otherNodeId of outgoingEdges.get(node)) { 85 | const otherLoc = nodeLayouts.get(otherNodeId)!; 86 | if (otherLoc) { 87 | column = Math.max(column, otherLoc.column + 1); 88 | } 89 | } 90 | 91 | const { namedInputs } = doc.findModuleInputIds(node.id); 92 | 93 | while (!columns[column]) columns.push([]); 94 | const index = columns[column].length; 95 | nodeLayouts.set(node.id, { 96 | column, 97 | index, 98 | y: 0, 99 | height: getNodeHeight(doc, node), 100 | acceptsInputs: node.plugin.acceptsInputs, 101 | namedInputs: new Set(namedInputs.keys()), 102 | }); 103 | columns[column].push(node); 104 | } 105 | 106 | let colIndex = 0; 107 | for (const col of columns) { 108 | col.sort((a, b) => { 109 | if (!a) return -1; 110 | if (!b) return 1; 111 | return indices.get(a.id)! - indices.get(b.id)!; 112 | }); 113 | let y = 0; 114 | for (let i = 0; i < col.length; i++) { 115 | const layout = nodeLayouts.get(col[i]?.id || MOD_OUTPUT)!; 116 | layout.column = columns.length - 1 - colIndex; 117 | layout.index = i; 118 | layout.y = y; 119 | y += layout.height; 120 | y += MIN_ROW_GAP; 121 | y = Math.ceil(y / GRID_SIZE) * GRID_SIZE; 122 | } 123 | colIndex++; 124 | } 125 | 126 | return { 127 | columns: columns.reverse(), 128 | layouts: nodeLayouts, 129 | indices, 130 | }; 131 | } 132 | 133 | function getNodeHeight(doc: Document, mod: AnyModule) { 134 | let height = MOD_HEADER_HEIGHT; 135 | if (mod.plugin.acceptsInputs) height += MOD_INPUT_HEIGHT; 136 | height += MOD_OUTPUT_HEIGHT; 137 | const { namedInputs } = doc.findModuleInputIds(mod.id); 138 | for (let i = 0; i < namedInputs.size; i++) { 139 | height += MOD_NAMED_INPUT_HEIGHT; 140 | } 141 | if (mod.plugin.acceptsNamedInputs) height += MOD_NAMED_INPUT_HEIGHT; 142 | return height; 143 | } 144 | -------------------------------------------------------------------------------- /src/ui/components/module-graph/consts.ts: -------------------------------------------------------------------------------- 1 | export const MOD_BASE_WIDTH = 128; 2 | export const MOD_HEADER_HEIGHT = 24; 3 | export const MOD_INPUT_HEIGHT = 20; 4 | export const MOD_OUTPUT_HEIGHT = 20; 5 | export const MOD_NAMED_INPUT_HEIGHT = 24; 6 | export const MIN_COL_GAP = 64; 7 | export const MIN_ROW_GAP = 24; 8 | export const GRID_SIZE = 24; 9 | -------------------------------------------------------------------------------- /src/ui/components/module-graph/eggbug-sleep.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/ui/components/module-graph/eggbug.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/ui/components/module-graph/index.css: -------------------------------------------------------------------------------- 1 | .module-graph { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | 6 | .react-flow-bridge { 7 | flex: 1; 8 | } 9 | 10 | .i-actions { 11 | position: absolute; 12 | top: 1em; 13 | left: 1em; 14 | } 15 | 16 | .react-flow__connection-path { 17 | stroke: var(--P-graph-connection); 18 | stroke-width: 2px; 19 | } 20 | 21 | .react-flow__handle { 22 | --color: unset; 23 | background: var(--color); 24 | border-color: var(--P-graph-connection); 25 | 26 | &::before { 27 | /* increase hitbox */ 28 | /* interaction signifier */ 29 | content: ''; 30 | position: absolute; 31 | inset: -6px; 32 | border-radius: 50%; 33 | } 34 | } 35 | 36 | .i-connection { 37 | .react-flow__edge-path { 38 | stroke: var(--P-graph-connection); 39 | stroke-width: 2px; 40 | stroke-linecap: round; 41 | } 42 | 43 | &.selected .react-flow__edge-path, 44 | &.is-highlighted .react-flow__edge-path { 45 | stroke: var(--P-graph-connection-highlighted); 46 | stroke-width: 4px; 47 | } 48 | } 49 | 50 | &:not(.is-dragging-node) { 51 | .react-flow__node { 52 | transition: transform 0.3s; 53 | } 54 | .react-flow__edge-path { 55 | transition: d 0.3s; 56 | } 57 | } 58 | 59 | .i-module-item { 60 | background: var(--P-graph-node-bg); 61 | --shading: inset 0 1px rgba(255, 255, 255, 0.3), inset 0 -1px rgba(0, 0, 0, 0.3); 62 | --conn-port-in: rgb(var(--P-color-accent)); 63 | --conn-port-out: rgb(var(--P-color-accent)); 64 | --selected: rgb(var(--P-color-accent)); 65 | box-shadow: var(--shading); 66 | border-radius: 0.5em; 67 | cursor: default; 68 | 69 | &.is-selected { 70 | background: var(--P-graph-node-selected-bg); 71 | box-shadow: var(--shading), 0 0 0 2px var(--selected); 72 | } 73 | 74 | &.is-error { 75 | --selected: var(--P-error); 76 | --conn-port-out: var(--P-error); 77 | 78 | .i-header { 79 | background: var(--P-error); 80 | color: var(--P-error-content-fg); 81 | } 82 | } 83 | 84 | .i-header { 85 | height: calc(var(--height) * 1px); 86 | line-height: calc(var(--height) * 1px); 87 | border-radius: 0.5em 0.5em 0 0; 88 | padding: 0 0.2em; 89 | overflow: hidden; 90 | white-space: nowrap; 91 | text-overflow: ellipsis; 92 | 93 | .i-index { 94 | font-weight: bold; 95 | display: inline-block; 96 | min-width: 1em; 97 | text-align: right; 98 | margin-right: 0.2em; 99 | } 100 | } 101 | .i-input, 102 | .i-output { 103 | height: calc(var(--height) * 1px); 104 | line-height: calc(var(--height) * 1px); 105 | white-space: nowrap; 106 | font-size: smaller; 107 | padding: 0 0.5em; 108 | position: relative; 109 | display: flex; 110 | 111 | &::before { 112 | content: ''; 113 | position: absolute; 114 | left: 0; 115 | top: 50%; 116 | margin-top: -0.5em; 117 | border: 0.5em solid transparent; 118 | border-left-color: var(--conn-port-in); 119 | } 120 | 121 | .i-label { 122 | overflow: hidden; 123 | text-overflow: ellipsis; 124 | } 125 | 126 | .react-flow__handle { 127 | --color: var(--conn-port-in); 128 | } 129 | } 130 | 131 | .i-input { 132 | padding-left: 0.7em; 133 | } 134 | 135 | .i-output { 136 | justify-content: flex-end; 137 | padding-right: 0.7em; 138 | 139 | &::before { 140 | left: auto; 141 | right: 0; 142 | border-left-color: transparent; 143 | border-right-color: var(--conn-port-out); 144 | } 145 | 146 | .react-flow__handle { 147 | --color: var(--conn-port-out); 148 | } 149 | } 150 | 151 | .i-named-inputs { 152 | box-shadow: inset 0 1px var(--P-separator); 153 | 154 | .i-named-input { 155 | height: calc(var(--height) * 1px); 156 | line-height: calc(var(--height) * 1px); 157 | font-size: smaller; 158 | white-space: nowrap; 159 | padding: 0 0.5em; 160 | padding-left: 0.7em; 161 | position: relative; 162 | display: flex; 163 | 164 | .i-label { 165 | overflow: hidden; 166 | text-overflow: ellipsis; 167 | } 168 | 169 | &::before { 170 | content: ''; 171 | position: absolute; 172 | left: 0; 173 | top: 50%; 174 | margin-top: -0.5em; 175 | border: 0.5em solid transparent; 176 | border-left-color: var(--conn-port-in); 177 | } 178 | 179 | .react-flow__handle { 180 | --color: var(--conn-port-in); 181 | } 182 | } 183 | } 184 | } 185 | 186 | .i-output-node { 187 | cursor: grab; 188 | 189 | &.is-patting { 190 | cursor: grabbing; 191 | } 192 | 193 | .eggbug-containment-zone { 194 | width: 48px; 195 | height: 48px; 196 | transform-origin: 50% 90%; 197 | 198 | svg { 199 | width: 100%; 200 | height: 100%; 201 | } 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/ui/components/module-graph/module-node.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Position, Handle, useUpdateNodeInternals } from 'reactflow'; 3 | import { AnyModule, Data } from '../../../document'; 4 | import { 5 | MOD_BASE_WIDTH, 6 | MOD_HEADER_HEIGHT, 7 | MOD_INPUT_HEIGHT, 8 | MOD_OUTPUT_HEIGHT, 9 | MOD_NAMED_INPUT_HEIGHT, 10 | } from './consts'; 11 | const HEIGHT_PROP = '--height' as any; 12 | 13 | export function ModuleNode({ data }: { data: ModuleNode.NodeData }) { 14 | const { index, module, selected, namedInputs, currentOutput, currentError } = data; 15 | const updateNodeInternals = useUpdateNodeInternals(); 16 | 17 | useEffect(() => { 18 | // need to inform reactflow that inputs have changed 19 | updateNodeInternals(module.id); 20 | }, [namedInputs]); 21 | 22 | const modDesc = module.title || module.plugin.description(module.data); 23 | 24 | return ( 25 |
41 |
42 | {index + 1} 43 | {modDesc} 44 |
45 | {module.plugin.acceptsInputs ? ( 46 |
47 | input 48 | 49 |
50 | ) : null} 51 | 52 |
53 | {[...namedInputs].map((name, i) => ( 54 |
61 | {name} 62 | 63 |
64 | ))} 65 | {module.plugin.acceptsNamedInputs ? ( 66 |
73 | + 74 | 75 |
76 | ) : null} 77 |
78 |
79 | ); 80 | } 81 | export namespace ModuleNode { 82 | export interface NodeData { 83 | index: number; 84 | module: AnyModule; 85 | namedInputs: Set; 86 | selected?: boolean; 87 | currentOutput: Data | null; 88 | currentError: unknown | null; 89 | } 90 | } 91 | 92 | function ModuleOutput({ data }: { data: Data | null }) { 93 | let contents = 'output'; 94 | if (data) { 95 | contents = data.typeDescription(); 96 | } 97 | 98 | return ( 99 |
100 | {contents} 101 | 102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/ui/components/module-graph/output-node.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef, useState } from 'react'; 2 | import { Position, Handle } from 'reactflow'; 3 | // @ts-ignore 4 | import eggbug from 'string:eggbug.svg'; 5 | // @ts-ignore 6 | import eggbugSleep from 'string:eggbug-sleep.svg'; 7 | import { AnimationController, Spring } from '../../../uikit/frame-animation'; 8 | 9 | export function OutputNode({ data }: { data: any }) { 10 | const node = useRef(null); 11 | 12 | const [isPointerDown, setPointerDown] = useState(false); 13 | const bounceSpring = useMemo(() => new Spring({ stiffness: 158, damping: 18 }), []); 14 | const [bounceTransform, setBounceTransform] = useState(''); 15 | 16 | const setBounceTransformRef = useRef(setBounceTransform); 17 | setBounceTransformRef.current = setBounceTransform; 18 | 19 | const animCtrl = useMemo(() => new AnimationController(), []); 20 | useEffect(() => { 21 | return () => animCtrl.stop(); 22 | }, []); 23 | 24 | const animationTarget = useMemo(() => { 25 | return { 26 | update: (dt: number) => { 27 | const setBounceTransform = setBounceTransformRef.current; 28 | 29 | const isDone = bounceSpring.update(dt); 30 | const sx = Math.max(0, Math.min(1 + bounceSpring.value, 4)); 31 | const sy = Math.max(0, Math.min(1 - bounceSpring.value, 4)); 32 | setBounceTransform(`scale(${sx}, ${sy})`); 33 | 34 | return isDone; 35 | }, 36 | }; 37 | }, []); 38 | 39 | const addBounce = (velocity: number) => { 40 | if (Math.abs(bounceSpring.velocity) > 40) return; 41 | bounceSpring.velocity += velocity; 42 | animCtrl.add(animationTarget); 43 | }; 44 | 45 | const lastPointer = useRef([0, 0]); 46 | const onDown = (e: React.PointerEvent) => { 47 | e.preventDefault(); 48 | node.current!.setPointerCapture(e.pointerId); 49 | lastPointer.current = [e.clientX, e.clientY]; 50 | setPointerDown(true); 51 | }; 52 | const onMove = (e: React.PointerEvent) => { 53 | if (!isPointerDown) return; 54 | const dx = e.clientX - lastPointer.current[0]; 55 | const dy = e.clientY - lastPointer.current[0]; 56 | 57 | const dist = Math.sqrt(Math.hypot(dx, dy)) * (0.5 + 0.5 * Math.random()); 58 | addBounce(dist * 0.02); 59 | 60 | lastPointer.current = [e.clientX, e.clientY]; 61 | }; 62 | const onUp = (e: React.PointerEvent) => { 63 | node.current!.releasePointerCapture(e.pointerId); 64 | setPointerDown(false); 65 | }; 66 | 67 | return ( 68 |
76 | 77 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/ui/components/module-graph/reactflow.tsx: -------------------------------------------------------------------------------- 1 | import InnerReactFlow, { Controls, Background, ReactFlowProps } from 'reactflow'; 2 | import { GRID_SIZE } from './consts'; 3 | import { ModuleNode } from './module-node'; 4 | import { OutputNode } from './output-node'; 5 | 6 | const nodeTypes = { 7 | module: ModuleNode, 8 | modOutput: OutputNode, 9 | }; 10 | 11 | export default function ReactFlow(props: ReactFlowProps) { 12 | return ( 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/ui/components/module-picker.css: -------------------------------------------------------------------------------- 1 | .module-picker-items { 2 | width: 400px; 3 | 4 | .module-picker-item { 5 | padding: 0.75em 1em; 6 | display: flex; 7 | gap: 0.5em; 8 | align-items: center; 9 | position: relative; 10 | 11 | &:not(:last-child)::after { 12 | content: ''; 13 | position: absolute; 14 | bottom: 0; 15 | left: 1em; 16 | right: 1em; 17 | border-bottom: 1px solid var(--P-separator); 18 | } 19 | 20 | & > .i-details { 21 | flex: 1; 22 | 23 | h3, 24 | p { 25 | margin: 0; 26 | } 27 | h3 { 28 | font-size: inherit; 29 | } 30 | p { 31 | font-size: smaller; 32 | } 33 | } 34 | 35 | & > .i-add-button { 36 | width: 2.3em; 37 | height: 2.3em; 38 | background: var(--P-separator); 39 | border: none; 40 | padding: 0; 41 | margin: 0; 42 | color: inherit; 43 | position: relative; 44 | border-radius: 50%; 45 | 46 | &::before, 47 | &::after { 48 | content: ''; 49 | position: absolute; 50 | width: 1.2em; 51 | height: 2px; 52 | background: currentColor; 53 | top: 50%; 54 | left: 50%; 55 | transform: translate(-50%, -50%); 56 | } 57 | &::after { 58 | transform: translate(-50%, -50%) rotate(90deg); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ui/components/module-picker.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { DirPopover } from '../../uikit/dir-popover'; 3 | import { ModuleDef, MODULES } from '../../plugins'; 4 | import { ModulePlugin, JsonValue } from '../../document'; 5 | import './module-picker.css'; 6 | 7 | export function ModulePicker({ open, anchor, onClose, onPick }: ModulePicker.Props) { 8 | return ( 9 | 10 |
11 | {Object.keys(MODULES).map((moduleId) => ( 12 | { 16 | onPick(await MODULES[moduleId].load()); 17 | }} 18 | /> 19 | ))} 20 |
21 |
22 | ); 23 | } 24 | namespace ModulePicker { 25 | export interface Props { 26 | open: boolean; 27 | anchor?: HTMLElement | [number, number] | null; 28 | onClose: () => void; 29 | onPick: (m: ModulePlugin) => void; 30 | } 31 | } 32 | 33 | function Module({ module, onPick }: { module: ModuleDef; onPick: () => void }) { 34 | const [titleId] = useMemo(() => Math.random().toString(36), []); 35 | 36 | return ( 37 |
38 |
39 |

{module.title}

40 |

{module.description}

41 |
42 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/ui/components/module-status.css: -------------------------------------------------------------------------------- 1 | .module-status { 2 | min-height: 1.5em; 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/components/module-status.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './module-status.css'; 3 | 4 | /** Displays a status line. */ 5 | export function ModuleStatus({ children }: { children: React.ReactNode }) { 6 | return
{children}
; 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/components/post-preview/dark-theme-button.css: -------------------------------------------------------------------------------- 1 | .dark-theme-button { 2 | display: flex; 3 | gap: 0.2em; 4 | align-items: center; 5 | appearance: none; 6 | background: none; 7 | font: inherit; 8 | color: inherit; 9 | margin: 0; 10 | padding: 0 0.3em 0 0.1em; 11 | border: 1px solid rgb(var(--P-color-accent)); 12 | border-radius: 0.5em; 13 | 14 | &:active { 15 | opacity: 0.5; 16 | } 17 | 18 | > .i-icon { 19 | width: 20px; 20 | height: 20px; 21 | background: currentColor; 22 | } 23 | 24 | > .i-label { 25 | display: grid; 26 | 27 | > * { 28 | grid-area: 1 / 1; 29 | transition: opacity 1s; 30 | } 31 | > .i-dark { 32 | opacity: 0; 33 | } 34 | } 35 | &.is-dark > .i-label { 36 | > .i-light { 37 | opacity: 0; 38 | } 39 | > .i-dark { 40 | opacity: 1; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/components/post-preview/dark-theme-button.tsx: -------------------------------------------------------------------------------- 1 | import './dark-theme-button.css'; 2 | import { useAnimation, useSpring } from '../../../uikit/animation'; 3 | import { useRef } from 'react'; 4 | 5 | function makeIcon({ 6 | r0, 7 | r1, 8 | r2, 9 | angle, 10 | centerX, 11 | centerY, 12 | }: { 13 | r0: number; 14 | r1: number; 15 | r2: number; 16 | angle: number; 17 | centerX: number; 18 | centerY: number; 19 | }) { 20 | const cx = (d: number) => centerX + Math.cos(angle) * d; 21 | const cy = (d: number) => centerY + Math.sin(angle) * d; 22 | 23 | const d = [ 24 | // outer circle 25 | `M ${cx(-r0)} ${cy(-r0)}`, 26 | `A ${r0} ${r0} 0 1 0 ${cx(r0)} ${cy(r0)}`, 27 | `A ${r0} ${r0} 0 1 0 ${cx(-r0)} ${cy(-r0)}`, 28 | 29 | // inner circle 30 | `M ${cx(-r2)} ${cy(-r2)}`, 31 | `A ${r2} ${r2} 0 1 0 ${cx(r2)} ${cy(r2)}`, 32 | `A ${r2} ${r2} 0 1 0 ${cx(-r2)} ${cy(-r2)}`, 33 | 34 | // invert 35 | `M ${cx(-r1)} ${cy(-r1)}`, 36 | `A ${r1} ${r1} 0 1 0 ${cx(r1)} ${cy(r1)}`, 37 | ].join(' '); 38 | 39 | return `path(evenodd, "${d}")`; 40 | } 41 | 42 | export function DarkThemeButton({ isDark, onClick }: { isDark: boolean; onClick: () => void }) { 43 | const iconRef = useRef(null); 44 | const dark = useSpring({ value: isDark ? 1 : 0, period: 0.9 }); 45 | dark.setTarget(isDark ? 1 : 0); 46 | 47 | const anim = useAnimation( 48 | iconRef, 49 | { dark }, 50 | ({ dark }) => { 51 | const r0 = 8 - dark * 4.3; 52 | const r1 = 7; 53 | const r2 = 3.7 + dark * 4.3; 54 | const angle = Math.PI / 2 + dark * Math.PI; 55 | 56 | return { 57 | clipPath: makeIcon({ 58 | r0, 59 | r1, 60 | r2, 61 | angle, 62 | centerX: 10, 63 | centerY: 10, 64 | }), 65 | }; 66 | }, 67 | { 68 | keyframeTimeStep: 1 / 240, 69 | } 70 | ); 71 | 72 | const styles = anim.getCurrentStyles(); 73 | 74 | const label = isDark ? 'switch to light theme' : 'switch to dark theme'; 75 | 76 | return ( 77 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /src/ui/components/preview.scss: -------------------------------------------------------------------------------- 1 | .data-preview { 2 | display: flex; 3 | flex-direction: column; 4 | box-sizing: border-box; 5 | 6 | & > .i-contents { 7 | flex: 1; 8 | height: 0; 9 | display: grid; 10 | grid-template-columns: 100%; 11 | grid-template-rows: 100%; 12 | 13 | /* show site theme in background color */ 14 | &:has(> .i-preview-area > .i-post-preview) { 15 | background: linear-gradient(to bottom, var(--P-cohost-bg), rgb(var(--P-color-bg))); 16 | } 17 | &:has(> .i-preview-area > .i-post-preview > .is-site-dark-theme) { 18 | background: var(--P-cohost-dark-bg); 19 | } 20 | 21 | & > .i-preview-area { 22 | grid-area: 1 / 1; 23 | overflow: auto; 24 | isolation: isolate; 25 | } 26 | 27 | & > .i-error-area { 28 | grid-area: 1 / 1; 29 | position: relative; 30 | overflow: auto; 31 | 32 | &:empty { 33 | display: none; 34 | } 35 | } 36 | } 37 | 38 | & > .preview-header { 39 | padding: 0.5em 1em; 40 | display: flex; 41 | justify-content: space-between; 42 | align-items: center; 43 | gap: 0.5em; 44 | position: relative; 45 | border-bottom: 1px solid rgb(var(--P-color-accent)); 46 | box-shadow: 0 1px 2px #0002; 47 | z-index: 1; 48 | 49 | .preview-config { 50 | display: flex; 51 | align-items: center; 52 | gap: 0.5em; 53 | flex: 1; 54 | 55 | .output-select { 56 | width: 100%; 57 | max-width: 8em; 58 | } 59 | } 60 | 61 | .render-indicator { 62 | position: relative; 63 | width: 1.2em; 64 | height: 1.2em; 65 | line-height: 0; 66 | visibility: hidden; 67 | opacity: 0; 68 | 69 | &::before { 70 | content: ''; 71 | display: inline-block; 72 | width: 100%; 73 | height: 100%; 74 | box-sizing: border-box; 75 | border-radius: 50%; 76 | border: 2px solid rgb(var(--P-color-accent)); 77 | border-bottom-color: transparent; 78 | } 79 | 80 | &.is-rendering { 81 | opacity: 1; 82 | visibility: visible; 83 | transition: opacity 0.1s; 84 | 85 | &::before { 86 | animation: P-data-preview-render-indicator-spin 1s infinite linear; 87 | @keyframes P-data-preview-render-indicator-spin { 88 | 0% { 89 | transform: none; 90 | } 91 | 100% { 92 | transform: rotate(360deg); 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | & > .i-contents > .i-preview-area > .preview-error-placeholder { 101 | background: rgb(var(--P-card-bg)); 102 | border-radius: 0.5em; 103 | padding: 0.5em 1em; 104 | margin: 1em; 105 | user-select: text; 106 | box-sizing: border-box; 107 | } 108 | 109 | & > .i-contents > .i-error-area > .preview-error { 110 | background: rgb(var(--P-card-bg)); 111 | border: 2px solid var(--P-error); 112 | margin: 1em; 113 | padding: 0.5em 1em; 114 | border-radius: 0.5em; 115 | 116 | .error-title { 117 | font-weight: bold; 118 | } 119 | 120 | .error-contents { 121 | margin-top: 0.5em; 122 | padding-top: 0.5em; 123 | border-top: 1px solid var(--P-separator); 124 | white-space: pre-wrap; 125 | font-family: var(--P-monospace); 126 | font-size: smaller; 127 | overflow-wrap: break-word; 128 | } 129 | 130 | .error-source { 131 | margin-top: 1em; 132 | 133 | .inner-title { 134 | font-weight: bold; 135 | margin-bottom: 0.5em; 136 | } 137 | } 138 | } 139 | 140 | & > .i-contents > .i-preview-area > .i-post-preview { 141 | padding: 1em; 142 | } 143 | 144 | & > .i-contents > .i-preview-area > .i-data-preview { 145 | margin: 1em; 146 | border-radius: 0.5em; 147 | background: rgb(var(--P-card-bg)); 148 | user-select: text; 149 | contain: layout; 150 | } 151 | 152 | & > .i-contents > .i-preview-area > .i-preview-click-to-render { 153 | display: flex; 154 | flex-direction: column; 155 | gap: 0.5em; 156 | align-items: center; 157 | 158 | button { 159 | font: inherit; 160 | background: rgb(var(--P-color-accent)); 161 | color: rgb(var(--P-color-accent-fg)); 162 | border: none; 163 | padding: 0.5em 2em; 164 | border-radius: 0.5em; 165 | box-shadow: inset 0 1px #fff8, inset 0 -1px #0006, 0 1px 2px #000a; 166 | 167 | &:active { 168 | opacity: 0.5; 169 | } 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/ui/components/split-panel.scss: -------------------------------------------------------------------------------- 1 | .split-panel { 2 | display: flex; 3 | align-items: stretch; 4 | width: 100%; 5 | height: 100%; 6 | 7 | & > .inner-container { 8 | overflow: hidden; 9 | 10 | & > * { 11 | width: 100%; 12 | height: 100%; 13 | } 14 | } 15 | 16 | & > .inner-resizer { 17 | position: relative; 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | flex-basis: 1em; 22 | flex-shrink: 0; 23 | cursor: col-resize; 24 | box-shadow: inset 1px 0 var(--P-resizer-rim), inset -1px 0 var(--P-resizer-rim); 25 | 26 | &::before { 27 | content: ''; 28 | position: absolute; 29 | inset: 0; 30 | transform: scaleX(0); 31 | background: var(--P-separator); 32 | transition: transform 0.1s cubic-bezier(0.5, 0, 0.7, 0.5); 33 | } 34 | @media (hover: hover) { 35 | &:hover::before { 36 | transform: scaleX(0.5); 37 | transition: transform 0.3s cubic-bezier(0.5, 2, 0.5, 1); 38 | } 39 | } 40 | @media (hover: none) { 41 | &:active::before { 42 | transform: scaleX(0.5); 43 | transition: transform 0.3s cubic-bezier(0.5, 2, 0.5, 1); 44 | } 45 | } 46 | 47 | & > .inner-affordance { 48 | position: relative; 49 | width: 1em; 50 | height: 1em; 51 | --line-pos: 30%; 52 | --line-weight: 2px; 53 | 54 | &::before, 55 | &::after { 56 | position: absolute; 57 | content: ''; 58 | background: currentColor; 59 | } 60 | } 61 | } 62 | 63 | &.is-horizontal > .inner-resizer > .inner-affordance { 64 | &::before, 65 | &::after { 66 | height: 1em; 67 | width: var(--line-weight); 68 | top: 0; 69 | } 70 | &::before { 71 | left: var(--line-pos); 72 | } 73 | &::after { 74 | right: var(--line-pos); 75 | } 76 | } 77 | &.is-vertical > .inner-resizer > .inner-affordance { 78 | &::before, 79 | &::after { 80 | width: 1em; 81 | height: var(--line-weight); 82 | left: 0; 83 | } 84 | &::before { 85 | top: var(--line-pos); 86 | } 87 | &::after { 88 | bottom: var(--line-pos); 89 | } 90 | } 91 | 92 | &.is-vertical { 93 | flex-direction: column; 94 | 95 | & > .inner-resizer { 96 | cursor: row-resize; 97 | box-shadow: inset 0 1px var(--P-resizer-rim), inset 0 -1px var(--P-resizer-rim); 98 | 99 | &::before { 100 | transform: scaleY(0); 101 | } 102 | &:hover::before { 103 | transform: scaleY(0.5); 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/ui/components/split-panel.tsx: -------------------------------------------------------------------------------- 1 | import React, { createRef, PureComponent } from 'react'; 2 | import './split-panel.scss'; 3 | 4 | type ResizerDragState = { 5 | pointerId: number; 6 | offset: number; 7 | }; 8 | 9 | interface SplitPanelState { 10 | splitPos: number; 11 | } 12 | 13 | export class SplitPanel extends PureComponent { 14 | state = { 15 | splitPos: Number.isFinite(this.props.initialPos) ? this.props.initialPos! : 0.5, 16 | }; 17 | 18 | panel = createRef(); 19 | resizer = createRef(); 20 | 21 | hv(x: T, y: T): T { 22 | return this.props.vertical ? y : x; 23 | } 24 | 25 | get bounds() { 26 | const minSplit = this.props.bounds ? this.props.bounds[0] : 0.1; 27 | const maxSplit = this.props.bounds ? this.props.bounds[1] : 0.9; 28 | return [minSplit, maxSplit]; 29 | } 30 | 31 | resizerDragState: ResizerDragState | null = null; 32 | onResizerPointerDown = (e: React.PointerEvent) => { 33 | e.preventDefault(); 34 | if (this.resizerDragState) return; 35 | this.resizer.current!.setPointerCapture(e.pointerId); 36 | 37 | const panelRect = this.panel.current!.getBoundingClientRect(); 38 | const panelBase = this.hv(panelRect.left, panelRect.top); 39 | const panelSize = this.hv(panelRect.width, panelRect.height); 40 | const pointerLoc = this.hv(e.clientX, e.clientY); 41 | 42 | const normalizedPointerLoc = (pointerLoc - panelBase) / panelSize; 43 | this.resizerDragState = { 44 | pointerId: e.pointerId, 45 | offset: this.state.splitPos - normalizedPointerLoc, 46 | }; 47 | }; 48 | onResizerPointerMove = (e: React.PointerEvent) => { 49 | e.preventDefault(); 50 | if (!this.resizerDragState) return; 51 | const state = this.resizerDragState; 52 | 53 | const panelRect = this.panel.current!.getBoundingClientRect(); 54 | const panelBase = this.hv(panelRect.left, panelRect.top); 55 | const panelSize = this.hv(panelRect.width, panelRect.height); 56 | const pointerLoc = this.hv(e.clientX, e.clientY); 57 | 58 | const normalizedPointerLoc = (pointerLoc - panelBase) / panelSize; 59 | 60 | const [minSplit, maxSplit] = this.bounds; 61 | this.setState({ 62 | splitPos: Math.max(minSplit, Math.min(normalizedPointerLoc + state.offset, maxSplit)), 63 | }); 64 | }; 65 | onResizerPointerUp = (e: React.PointerEvent) => { 66 | e.preventDefault(); 67 | if (!this.resizerDragState) return; 68 | const state = this.resizerDragState; 69 | this.resizer.current!.releasePointerCapture(state.pointerId); 70 | this.resizerDragState = null; 71 | }; 72 | 73 | onResizerKeyPress = (e: React.KeyboardEvent) => { 74 | const [minSplit, maxSplit] = this.bounds; 75 | 76 | const panelRect = this.panel.current!.getBoundingClientRect(); 77 | const panelSize = this.hv(panelRect.width, panelRect.height); 78 | const increment = 10 / panelSize; 79 | 80 | const primaryDirUp = this.hv('ArrowRight', 'ArrowDown'); 81 | const primaryDirDn = this.hv('ArrowLeft', 'ArrowUp'); 82 | const secondaryDirUp = this.hv('ArrowUp', 'ArrowRight'); 83 | const secondaryDirDn = this.hv('ArrowDown', 'ArrowLeft'); 84 | 85 | if (e.key === primaryDirUp || e.key === secondaryDirUp) { 86 | const newPos = this.state.splitPos + increment; 87 | this.setState({ 88 | splitPos: Math.max(minSplit, Math.min(newPos, maxSplit)), 89 | }); 90 | } else if (e.key === primaryDirDn || e.key === secondaryDirDn) { 91 | const newPos = this.state.splitPos - increment; 92 | this.setState({ 93 | splitPos: Math.max(minSplit, Math.min(newPos, maxSplit)), 94 | }); 95 | } 96 | }; 97 | 98 | render() { 99 | const { vertical, children: originalChildren } = this.props; 100 | 101 | let children: React.ReactNode[]; 102 | if (Array.isArray(originalChildren)) { 103 | children = originalChildren; 104 | } else { 105 | children = [originalChildren]; 106 | } 107 | children = children.filter((x: any) => x); 108 | if (children.length > 2) throw new Error('SplitPanel: more than 2 children not supported'); 109 | 110 | const doSplit = children.length > 1; 111 | const style1 = { 112 | [this.hv('width', 'height')]: doSplit ? this.state.splitPos * 100 + '%' : '100%', 113 | }; 114 | const style2 = { 115 | [this.hv('width', 'height')]: (1 - this.state.splitPos) * 100 + '%', 116 | }; 117 | const [minSplit, maxSplit] = this.bounds; 118 | 119 | return ( 120 |
124 |
125 | {children[0]} 126 |
127 | {doSplit && ( 128 |
e.preventDefault()} 139 | onPointerDown={this.onResizerPointerDown} 140 | onPointerMove={this.onResizerPointerMove} 141 | onPointerUp={this.onResizerPointerUp} 142 | > 143 |
144 |
145 | )} 146 | {doSplit && ( 147 |
148 | {children[1]} 149 |
150 | )} 151 |
152 | ); 153 | } 154 | } 155 | namespace SplitPanel { 156 | export interface Props { 157 | vertical?: boolean; 158 | /** The initial split position. (Default: 0.5) */ 159 | initialPos?: number; 160 | /** The min and max split values. (Default: [0.1, 0.9]) */ 161 | bounds?: [number, number]; 162 | children: React.ReactNode; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/ui/examples.css: -------------------------------------------------------------------------------- 1 | .examples-menu { 2 | .i-header { 3 | padding: 0.75em; 4 | 5 | .i-title { 6 | margin: 0; 7 | font-size: 1.2em; 8 | } 9 | .i-description { 10 | margin: 0; 11 | } 12 | } 13 | 14 | .i-loading, 15 | .i-error { 16 | padding: 0.75em; 17 | } 18 | .i-error { 19 | .i-error-text { 20 | color: var(--P-error-text); 21 | } 22 | 23 | .i-retry-container { 24 | text-align: center; 25 | margin-top: 0.5em; 26 | } 27 | } 28 | 29 | .i-items { 30 | margin: 0; 31 | padding: 0; 32 | list-style: none; 33 | transition: opacity 0.3s; 34 | 35 | &.is-loading-example { 36 | opacity: 0.5; 37 | pointer-events: none; 38 | } 39 | 40 | .i-example-item { 41 | position: relative; 42 | padding: 0.5em 0.75em; 43 | 44 | &:not(:last-child)::after { 45 | position: absolute; 46 | content: ''; 47 | left: 0.75em; 48 | right: 0.75em; 49 | bottom: 0; 50 | border-top: 1px solid var(--P-separator); 51 | } 52 | 53 | .i-inner { 54 | display: flex; 55 | gap: 0.5em; 56 | align-items: center; 57 | } 58 | 59 | .i-details { 60 | flex: 1; 61 | flex-basis: 0; 62 | 63 | .i-title { 64 | font-weight: bold; 65 | } 66 | } 67 | .i-actions { 68 | flex-shrink: 0; 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ui/examples.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from 'react'; 2 | import { Document } from '../document'; 3 | import { StorageContext } from '../storage-context'; 4 | import { Button } from '../uikit/button'; 5 | import { getExampleDocument } from '../storage'; 6 | import './examples.css'; 7 | 8 | interface ExamplesProps { 9 | onLoad: (doc: Document) => void; 10 | } 11 | 12 | interface ExampleDef { 13 | title: string; 14 | description: string; 15 | } 16 | 17 | export function ExamplesMenu({ onLoad }: ExamplesProps) { 18 | const [loading, setLoading] = useState(false); 19 | const [loadingExample, setLoadingExample] = useState(false); 20 | const [error, setError] = useState(null); 21 | const [items, setItems] = useState>({}); 22 | const storage = useContext(StorageContext); 23 | 24 | const load = () => { 25 | setLoading(true); 26 | setItems({}); 27 | setError(null); 28 | 29 | (async () => { 30 | const res = await fetch(new URL('../../assets/examples/index.json', import.meta.url)); 31 | if (!res.ok) throw await res.text(); 32 | return await res.json(); 33 | })() 34 | .then((items) => { 35 | setLoading(false); 36 | setItems(items); 37 | }) 38 | .catch((error) => { 39 | setLoading(false); 40 | setError(error); 41 | }); 42 | }; 43 | useEffect(() => { 44 | load(); 45 | }, []); 46 | 47 | const loadExample = (id: string) => { 48 | setLoadingExample(true); 49 | return getExampleDocument(id) 50 | .then((doc) => { 51 | onLoad(doc); 52 | }) 53 | .catch((err) => { 54 | throw new Error(`Error loading example: ${err?.message || err}`); 55 | }) 56 | .finally(() => { 57 | setLoadingExample(false); 58 | }); 59 | }; 60 | 61 | let contents; 62 | if (loading) { 63 | contents =
Loading…
; 64 | } else if (error) { 65 | contents = ( 66 |
67 |
68 | Could not load examples 69 |
70 | {error.toString()} 71 |
72 |
73 | 74 |
75 |
76 | ); 77 | } else { 78 | contents = ( 79 |
    80 | {Object.entries(items).map(([id, item]) => ( 81 | loadExample(id)} /> 82 | ))} 83 |
84 | ); 85 | } 86 | 87 | return ( 88 |
89 |
90 |

Examples and Templates

91 |

see how you can do things!

92 |
93 | {contents} 94 |
95 | ); 96 | } 97 | 98 | function ExampleItem({ item, onLoad }: { item: ExampleDef; onLoad: () => Promise }) { 99 | return ( 100 |
  • 101 |
    102 |
    103 |
    {item.title}
    104 |
    {item.description}
    105 |
    106 |
    107 | 110 |
    111 |
    112 |
  • 113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /src/ui/opt-held.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | export function useOptHeld(): boolean { 4 | const [optHeld, setOptHeld] = useState(false); 5 | 6 | const setOptHeldRef = useRef(setOptHeld); 7 | setOptHeldRef.current = setOptHeld; 8 | useEffect(() => { 9 | const onKeyDown = (e: KeyboardEvent) => { 10 | setOptHeldRef.current(e.altKey); 11 | }; 12 | const onKeyUp = (e: KeyboardEvent) => { 13 | setOptHeldRef.current(e.altKey); 14 | }; 15 | 16 | window.addEventListener('keydown', onKeyDown); 17 | window.addEventListener('keyup', onKeyUp); 18 | return () => { 19 | window.removeEventListener('keydown', onKeyDown); 20 | window.removeEventListener('keyup', onKeyUp); 21 | }; 22 | }, []); 23 | 24 | return optHeld; 25 | } 26 | -------------------------------------------------------------------------------- /src/ui/prechoster.scss: -------------------------------------------------------------------------------- 1 | .prechoster { 2 | display: flex; 3 | flex-direction: column; 4 | font-size: var(--P-font-size-ui); 5 | 6 | .prechoster-left-panel { 7 | display: flex; 8 | flex-direction: column; 9 | overflow: hidden scroll; 10 | } 11 | 12 | & > .menu-bar { 13 | display: flex; 14 | justify-content: space-between; 15 | align-items: center; 16 | flex-shrink: 0; 17 | background: black; 18 | color: white; 19 | font-size: smaller; 20 | padding: 0.2em; 21 | 22 | & > .i-buttons > button { 23 | background: none; 24 | font: inherit; 25 | color: inherit; 26 | border: 1px solid transparent; 27 | padding: 0.2em 0.3em; 28 | border-radius: 0.5em; 29 | margin: 0; 30 | 31 | &:disabled { 32 | opacity: 0.5; 33 | } 34 | 35 | &:active { 36 | background: rgba(255, 255, 255, 0.3); 37 | } 38 | } 39 | } 40 | 41 | & > .split-panel { 42 | flex: 1; 43 | flex-basis: 0; 44 | height: 0; 45 | } 46 | } 47 | 48 | .prechoster-document-settings { 49 | > .i-doc-title { 50 | display: grid; 51 | box-shadow: inset 0 -1px #0004; 52 | font-size: 1.1em; 53 | 54 | > .i-sizer, 55 | > .i-textarea { 56 | grid-area: 1 / 1; 57 | background: none; 58 | color: inherit; 59 | font: inherit; 60 | padding: 0.5em 1em; 61 | border: none; 62 | margin: 0; 63 | line-height: 1.4; 64 | font-weight: bold; 65 | white-space: pre-wrap; 66 | } 67 | 68 | > .i-sizer { 69 | visibility: hidden; 70 | } 71 | 72 | > .i-textarea { 73 | resize: none; 74 | overflow: clip; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ui/render-context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const RenderContext = createContext({ 4 | scheduleRender: () => {}, 5 | }); 6 | -------------------------------------------------------------------------------- /src/uikit/button-popout.css: -------------------------------------------------------------------------------- 1 | .uikit-popout-anchor { 2 | position: absolute; 3 | clip: rect(0 0 0 0); 4 | clip-path: inset(50%); 5 | height: 1px; 6 | width: 1px; 7 | white-space: nowrap; 8 | overflow: hidden; 9 | } 10 | 11 | .uikit-popout-satellite { 12 | position: fixed; 13 | top: 0; 14 | left: 0; 15 | width: 100vw; 16 | pointer-events: none; 17 | z-index: 1000; 18 | --popout-bg: rgb(var(--P-color-bg)); 19 | --popout-outline: rgb(var(--P-color-soft-outline) / var(--P-color-soft-outline-opacity)); 20 | --popout-accent: rgb(var(--P-color-accent)); 21 | 22 | .popout-satellite-inner { 23 | position: absolute; 24 | width: 100%; 25 | } 26 | 27 | .popout-pointer { 28 | position: absolute; 29 | background: var(--popout-bg); 30 | border: 1px solid var(--popout-outline); 31 | border-top-left-radius: 0.5em; 32 | border-right-color: transparent; 33 | border-bottom-color: transparent; 34 | transform-origin: 0 0; 35 | width: 1em; 36 | height: 1em; 37 | z-index: 1; 38 | } 39 | 40 | .popout-container { 41 | position: absolute; 42 | max-width: 100vw; 43 | background: var(--popout-bg); 44 | border-radius: 0.5em; 45 | border: 1px solid var(--popout-outline); 46 | pointer-events: all; 47 | 48 | .popout-contents { 49 | flex: 1; 50 | display: flex; 51 | 52 | .popout-close { 53 | display: none; 54 | border: none; 55 | background: none; 56 | margin: 0; 57 | padding: 0.5em 1em; 58 | font: inherit; 59 | color: inherit; 60 | line-height: 0; 61 | transition: opacity 0.3s var(--P-ease-default); 62 | 63 | .popout-close-icon { 64 | display: inline-block; 65 | position: relative; 66 | width: 1em; 67 | height: 1em; 68 | --hw: 0.07em; 69 | 70 | &::before, 71 | &::after { 72 | position: absolute; 73 | content: ''; 74 | width: 1em; 75 | height: calc(var(--hw) * 2); 76 | border-radius: var(--hw); 77 | top: 50%; 78 | left: 50%; 79 | background: currentColor; 80 | } 81 | &::before { 82 | transform: translate(-50%, -50%) rotate(45deg); 83 | } 84 | &::after { 85 | transform: translate(-50%, -50%) rotate(-45deg); 86 | } 87 | } 88 | 89 | &:active { 90 | opacity: 0.5; 91 | transition: opacity 0.05s var(--P-ease-default); 92 | } 93 | } 94 | .popout-text { 95 | position: relative; 96 | z-index: 1; 97 | padding: 0.5em 1em; 98 | 99 | &:empty { 100 | display: none; 101 | } 102 | } 103 | 104 | .popout-text:not(:empty) + .popout-action { 105 | border-left: 1px solid var(--popout-outline); 106 | } 107 | 108 | .popout-action { 109 | .popout-action-button { 110 | background: none; 111 | border: none; 112 | padding: 0.5em 1em; 113 | margin: 0; 114 | font: inherit; 115 | color: var(--popout-accent); 116 | transition: opacity 0.3s var(--P-ease-default); 117 | 118 | &:active { 119 | opacity: 0.5; 120 | transition: opacity 0.05s var(--P-ease-default); 121 | } 122 | } 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/uikit/button.css: -------------------------------------------------------------------------------- 1 | @property --uikit-button-color { 2 | syntax: ''; 3 | inherits: true; 4 | initial-value: black; 5 | } 6 | @property --uikit-button-fg { 7 | syntax: ''; 8 | inherits: true; 9 | initial-value: white; 10 | } 11 | 12 | .uikit-button { 13 | display: inline-flex; 14 | position: relative; 15 | transition: --uikit-button-color 0.3s var(--P-ease-default), 16 | --uikit-button-fg 0.3s var(--P-ease-default); 17 | 18 | --uikit-button-color: rgb(var(--P-color-accent)); 19 | --uikit-button-fg: rgb(var(--P-color-accent-fg)); 20 | --button-outline: rgb(var(--P-color-soft-outline) / var(--P-color-soft-outline-opacity)); 21 | --button-spin: rgb(var(--P-color-fg)); 22 | 23 | .button-inner { 24 | min-width: inherit; 25 | background: var(--uikit-button-color); 26 | color: var(--uikit-button-fg); 27 | border-radius: 100em; 28 | padding: 0.2em 0.8em; 29 | margin: 0; 30 | border: 1px solid var(--button-outline); 31 | font: inherit; 32 | font-weight: 500; 33 | transition: opacity 0.3s var(--P-ease-default); 34 | white-space: nowrap; 35 | -webkit-tap-highlight-color: transparent; 36 | 37 | &:focus { 38 | outline: none; 39 | } 40 | &:focus-visible { 41 | box-shadow: 0 0 0 0.2em rgb(var(--P-color-accent) / 0.5); 42 | animation: uikit-button-focus-in 0.3s var(--P-ease-default); 43 | } 44 | 45 | &::before { 46 | content: ''; 47 | position: absolute; 48 | top: 0; 49 | left: 0; 50 | right: 0; 51 | bottom: 0; 52 | border-radius: inherit; 53 | background: #000; 54 | opacity: 0; 55 | pointer-events: none; 56 | transition: opacity 0.3s var(--P-ease-default); 57 | } 58 | 59 | &:active::before { 60 | opacity: 0.3; 61 | transition: none; 62 | } 63 | } 64 | 65 | .button-loading { 66 | position: absolute; 67 | top: 0; 68 | left: 50%; 69 | transform: translateX(-50%); 70 | width: 100%; 71 | height: 100%; 72 | box-sizing: border-box; 73 | border-radius: 100em; 74 | border: 0.2em solid var(--uikit-button-color); 75 | pointer-events: none; 76 | opacity: 0; 77 | transition: opacity 0.3s var(--P-ease-default); 78 | } 79 | 80 | .button-loading-spin { 81 | position: absolute; 82 | top: 0; 83 | left: 0; 84 | right: 0; 85 | bottom: 0; 86 | pointer-events: none; 87 | transition: opacity 0.2s var(--P-ease-default); 88 | opacity: 0; 89 | } 90 | 91 | .button-loading-spin-inner { 92 | position: absolute; 93 | top: 50%; 94 | left: 50%; 95 | height: 100%; 96 | border: 0.2em solid transparent; 97 | border-bottom-color: var(--button-spin); 98 | border-radius: 100em; 99 | box-sizing: border-box; 100 | transform: translate(-50%, -50%); 101 | transition: opacity 0.2s var(--P-ease-default); 102 | } 103 | 104 | &.is-loading { 105 | .button-loading-spin-inner { 106 | animation: uikit-button-loading-inner 0.8s linear infinite; 107 | } 108 | } 109 | 110 | &.is-disabled { 111 | --uikit-button-fg: rgb(var(--P-color-accent-fg) / 0.5); 112 | } 113 | 114 | &.is-muted { 115 | --uikit-button-color: rgb(var(--P-color-shade) / var(--P-color-shade-opacity)); 116 | --uikit-button-fg: rgb(var(--P-color-fg)); 117 | 118 | &.is-disabled { 119 | --uikit-button-fg: rgb(var(--P-color-fg) / 0.5); 120 | } 121 | } 122 | 123 | &.is-danger { 124 | --uikit-button-color: rgb(var(--P-color-danger)); 125 | --uikit-button-fg: rgb(var(--P-color-danger-fg)); 126 | } 127 | } 128 | 129 | @keyframes uikit-button-loading-inner { 130 | 0% { 131 | transform: translate(-50%, -50%) rotate(0deg); 132 | } 133 | 100% { 134 | transform: translate(-50%, -50%) rotate(360deg); 135 | } 136 | } 137 | @keyframes uikit-button-focus-in { 138 | 0% { 139 | box-shadow: 0 0 0 1em rgb(var(--P-color-accent) / 0); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/uikit/button.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes, createRef, MouseEvent, PureComponent, ReactNode } from 'react'; 2 | import { ElAnim, Spring } from './animation'; 3 | import { ButtonPopout } from './button-popout'; 4 | import './button.css'; 5 | 6 | export namespace Button { 7 | export interface Props extends ButtonHTMLAttributes { 8 | run: (e?: MouseEvent) => Promise | void; 9 | /** external loading state. Can be used to force loading animation */ 10 | loading?: boolean; 11 | primary?: boolean; 12 | danger?: boolean; 13 | children?: ReactNode; 14 | } 15 | } 16 | 17 | export class Button extends PureComponent { 18 | state = { 19 | loading: false, 20 | popoutOpen: false, 21 | popoutMessage: null, 22 | popoutAction: null, 23 | }; 24 | 25 | #node = createRef(); 26 | #button = createRef(); 27 | #loadingNode = createRef(); 28 | #loadingSpin = createRef(); 29 | 30 | get node(): HTMLSpanElement | null { 31 | return this.#node.current; 32 | } 33 | 34 | elAnim = new ElAnim( 35 | ({ loading, width }) => { 36 | const buttonWidth = loading ? (this.#circleSize - width) * loading + width : 0; 37 | 38 | let buttonStyle = { 39 | opacity: Math.min(1, 1 - loading / 0.1), 40 | }; 41 | 42 | const loadingNodeStyle = { 43 | width: buttonWidth.toFixed(3) + 'px', 44 | opacity: Math.max(0, loading / 0.1), 45 | }; 46 | 47 | const loadingSpinStyle = { 48 | opacity: Math.max(0, loading - 0.9) / 0.1, 49 | zIndex: Math.round(Math.max(0, loading - 0.9) * 2), 50 | }; 51 | 52 | return [buttonStyle, loadingNodeStyle, loadingSpinStyle]; 53 | }, 54 | [this.#button, this.#loadingNode, this.#loadingSpin] 55 | ); 56 | 57 | widthAnim = new ElAnim( 58 | ({ width }) => { 59 | return { width: width.toFixed(3) + 'px' }; 60 | }, 61 | [this.#button], 62 | { 63 | useAnimationFillForwards: false, 64 | } 65 | ); 66 | 67 | #loading = new Spring(); 68 | #width = new Spring(); 69 | #circleSize = 0; 70 | 71 | updateWidth = (animate: boolean) => { 72 | const button = this.#button.current; 73 | if (!button) return; 74 | 75 | this.widthAnim.cancel(); // to get actual width 76 | this.#width.setTarget(button.offsetWidth); 77 | 78 | if (!animate) { 79 | this.#width.setValue(this.#width.target); 80 | } 81 | this.elAnim.resolve(); 82 | this.widthAnim.resolve(); 83 | }; 84 | 85 | get loading() { 86 | return this.state.loading || this.props.loading; 87 | } 88 | 89 | componentDidMount() { 90 | this.elAnim.didMount(); 91 | this.widthAnim.didMount(); 92 | this.updateMetrics(true); 93 | } 94 | 95 | componentDidUpdate(prevProps: Button.Props) { 96 | if (this.props.loading !== prevProps.loading) { 97 | if (this.props.loading) { 98 | this.setState({ popoutOpen: false }); 99 | } 100 | } 101 | if ( 102 | this.props.loading !== prevProps.loading || 103 | this.props.children !== prevProps.children 104 | ) { 105 | this.updateMetrics(); 106 | } 107 | } 108 | 109 | componentWillUnmount() { 110 | this.elAnim.drop(); 111 | } 112 | 113 | showError(error: any, action?: ButtonPopout.Action) { 114 | this.setState({ popoutOpen: true, popoutMessage: error, popoutAction: action || null }); 115 | } 116 | 117 | showAction(label: ReactNode, run: () => Promise) { 118 | this.setState({ 119 | popoutOpen: true, 120 | popoutMessage: null, 121 | popoutAction: { 122 | label, 123 | run: () => 124 | this.setState({ loading: true, popoutOpen: false }, () => { 125 | this.elAnim.resolve(); 126 | 127 | run() 128 | .catch((error: any) => { 129 | this.showError(error); 130 | }) 131 | .then(() => 132 | this.setState({ loading: false }, () => this.updateMetrics()) 133 | ); 134 | }), 135 | }, 136 | }); 137 | } 138 | 139 | updateMetrics(skipAnimation = false) { 140 | this.#circleSize = this.#button.current?.offsetHeight || 0; 141 | this.updateWidth(!skipAnimation); 142 | } 143 | 144 | run = (e?: any) => { 145 | if (this.loading) return; 146 | this.setState({ popoutOpen: false }); 147 | this.updateMetrics(); 148 | 149 | this.setState({ loading: true }, () => { 150 | this.elAnim.resolve(); 151 | 152 | const res = this.props.run(e) ?? Promise.resolve(); 153 | 154 | res.catch((error) => { 155 | console.error('button error', error); 156 | this.showError(error); 157 | }).then(() => { 158 | this.setState({ loading: false }, () => this.updateMetrics()); 159 | }); 160 | }); 161 | }; 162 | 163 | onClick = (e: MouseEvent) => { 164 | if (this.props.onClick) this.props.onClick(e as any); 165 | this.run(); 166 | }; 167 | 168 | render() { 169 | const { className: pClassName, disabled, primary, danger, children } = this.props; 170 | 171 | let className = 'uikit-button '; 172 | if (this.loading || this.#loading.getValue() > 0.5) className += 'is-loading '; 173 | if (disabled) className += 'is-disabled '; 174 | if (!primary) className += 'is-muted '; 175 | if (danger) className += 'is-danger '; 176 | className += pClassName || ''; 177 | 178 | this.#loading.setTarget(this.loading ? 1 : 0); 179 | this.elAnim.setInputs({ 180 | loading: this.#loading, 181 | width: this.#width, 182 | }); 183 | this.widthAnim.setInputs({ width: this.#width }); 184 | 185 | return ( 186 | 187 | 195 |
    196 |
    197 |
    203 |
    204 | 205 | this.setState({ popoutOpen: false })} 211 | /> 212 | 213 | ); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/uikit/checkbox.css: -------------------------------------------------------------------------------- 1 | .uikit-checkbox { 2 | display: inline-block; 3 | position: relative; 4 | width: 1em; 5 | height: 1em; 6 | border-radius: 0.3em; 7 | background: rgb(var(--P-color-bg)); 8 | border: 1px solid rgb(var(--P-color-soft-outline) / var(--P-color-soft-outline-opacity)); 9 | margin: 0.1em; 10 | vertical-align: middle; 11 | transition: background 0.3s var(--P-ease-default); 12 | 13 | .inner-checkbox { 14 | position: absolute; 15 | top: 0; 16 | left: 0; 17 | width: 100%; 18 | height: 100%; 19 | margin: 0; 20 | opacity: 0; 21 | } 22 | 23 | &.is-checked { 24 | background: rgb(var(--P-color-accent)); 25 | transition: background 0.1s var(--P-ease-default); 26 | } 27 | 28 | &.is-focused { 29 | box-shadow: 0 0 0 0.2em rgb(var(--P-color-accent) / 0.5); 30 | animation: uikit-checkbox-focus-ring-in 0.3s var(--P-ease-default); 31 | } 32 | 33 | --hw: 1px; 34 | --cdx: -0.08em; 35 | --cdy: 0.17em; 36 | --cos135: -0.7071067812; 37 | --sin135: -0.7071067812; 38 | --short: 0.35em; 39 | --actual-short: calc(var(--short) - (1.5 * var(--hw))); 40 | --long: 0.6em; 41 | --cross: 0.7em; 42 | 43 | .inner-check { 44 | position: absolute; 45 | top: 50%; 46 | left: 50%; 47 | pointer-events: none; 48 | color: rgb(var(--P-color-accent-fg)); 49 | 50 | &::before, 51 | &::after { 52 | content: ''; 53 | position: absolute; 54 | background: currentColor; 55 | margin-top: calc(var(--hw) * -1); 56 | margin-left: calc(var(--hw) * -1); 57 | height: calc(var(--hw) * 2); 58 | transform-origin: var(--hw) var(--hw); 59 | border-radius: var(--hw); 60 | } 61 | 62 | &::before { 63 | width: var(--short); 64 | --cpdx: calc(var(--cdx) + (var(--cos135) * var(--actual-short))); 65 | --cpdy: calc(var(--cdy) + (var(--sin135) * var(--actual-short))); 66 | transform: translate(var(--cpdx), var(--cpdy)) rotate(45deg); 67 | } 68 | &::after { 69 | width: var(--long); 70 | transform: translate(var(--cdx), var(--cdy)) rotate(-45deg); 71 | } 72 | } 73 | 74 | &.is-checked:not(.was-checked) .inner-check { 75 | &::before { 76 | animation: uikit-checkbox-inner-check-in-a 0.1s linear; 77 | } 78 | &::after { 79 | animation: uikit-checkbox-inner-check-in-a 0.2s cubic-bezier(0.1, 0.5, 0.2, 1) 0.1s 80 | backwards; 81 | } 82 | } 83 | 84 | &:not(.is-checked) .inner-check { 85 | opacity: 0; 86 | transform: scale(0.2); 87 | } 88 | 89 | &:not(.is-checked).was-checked .inner-check { 90 | animation: uikit-checkbox-inner-check-out 0.3s var(--P-ease-default); 91 | } 92 | 93 | .inner-cross { 94 | position: absolute; 95 | top: 50%; 96 | left: 50%; 97 | pointer-events: none; 98 | color: rgb(var(--P-color-accent-fg)); 99 | 100 | &::before, 101 | &::after { 102 | content: ''; 103 | position: absolute; 104 | background: currentColor; 105 | top: 50%; 106 | left: 50%; 107 | width: var(--cross); 108 | border-radius: var(--hw); 109 | height: calc(var(--hw) * 2); 110 | --rot: 45deg; 111 | transform: translate(-50%, -50%) rotate(var(--rot)); 112 | } 113 | &::after { 114 | --rot: -45deg; 115 | } 116 | } 117 | &.is-checked .inner-cross { 118 | opacity: 0; 119 | transform: scale(0.2); 120 | } 121 | &:not(.is-checked).was-checked .inner-cross { 122 | &::before, 123 | &::after { 124 | animation: uikit-checkbox-inner-cross-in-a 0.3s var(--P-ease-default) backwards; 125 | } 126 | &::after { 127 | animation-delay: 0.1s; 128 | } 129 | } 130 | } 131 | 132 | @keyframes uikit-checkbox-focus-ring-in { 133 | 0% { 134 | box-shadow: 0 0 0 1em rgb(var(--P-color-accent) / 0); 135 | } 136 | } 137 | @keyframes uikit-checkbox-inner-check-in-a { 138 | 0% { 139 | width: 0; 140 | } 141 | } 142 | @keyframes uikit-checkbox-inner-check-out { 143 | 0% { 144 | opacity: 1; 145 | transform: none; 146 | } 147 | } 148 | 149 | @keyframes uikit-checkbox-inner-cross-in-a { 150 | 0% { 151 | transform: translate(-50%, -50%) rotate(var(--rot)) translateX(calc(var(--cross) / -2)) 152 | scaleX(0); 153 | } 154 | 100% { 155 | transform: translate(-50%, -50%) rotate(var(--rot)) translateX(0) scaleX(1); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/uikit/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes, PureComponent } from 'react'; 2 | import './checkbox.css'; 3 | 4 | export namespace Checkbox { 5 | export interface Props extends Omit, 'onChange'> { 6 | checked: boolean; 7 | onChange?: (b: boolean) => void; 8 | falseCross?: boolean; 9 | } 10 | } 11 | export default class Checkbox extends PureComponent { 12 | state = { 13 | focused: false, 14 | }; 15 | 16 | wasChecked = this.props.checked; 17 | prevPropsChecked = this.props.checked; 18 | 19 | onFocus = (e: any) => { 20 | if (this.props.onFocus) this.props.onFocus(e); 21 | if (!e.defaultPrevented) this.setState({ focused: true }); 22 | }; 23 | onBlur = (e: any) => { 24 | if (this.props.onBlur) this.props.onBlur(e); 25 | if (!e.defaultPrevented) this.setState({ focused: false }); 26 | }; 27 | 28 | render() { 29 | const { checked, className: pClassName, onChange, falseCross, ...extra } = this.props; 30 | 31 | if (checked !== this.prevPropsChecked) { 32 | this.wasChecked = this.prevPropsChecked; 33 | this.prevPropsChecked = checked; 34 | } 35 | 36 | let className = 'uikit-checkbox '; 37 | if (checked) className += 'is-checked '; 38 | if (this.wasChecked) className += 'was-checked '; 39 | if (this.state.focused) className += 'is-focused '; 40 | className += pClassName || ''; 41 | 42 | return ( 43 | 44 | onChange && onChange(e.target.checked)} 52 | /> 53 | 54 | {!!falseCross && } 55 | 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/uikit/dir-popover.css: -------------------------------------------------------------------------------- 1 | .dir-popover-dialog { 2 | position: fixed; 3 | margin: 0; 4 | padding: 0; 5 | max-width: none; 6 | max-height: none; 7 | overflow: hidden; 8 | width: 100%; 9 | height: 100%; 10 | border: none; 11 | background: none; 12 | color: rgb(var(--P-color-fg)); 13 | 14 | &::backdrop { 15 | opacity: 0; 16 | } 17 | 18 | > .inner-backdrop { 19 | position: absolute; 20 | inset: 0; 21 | background: rgba(0, 0, 0, 0.2); 22 | } 23 | 24 | > .i-arrow { 25 | position: absolute; 26 | top: 0; 27 | left: 0; 28 | --arrow-size: 16px; 29 | --arrow-color: var(--P-dialog-bg); 30 | 31 | &::before, 32 | &::after { 33 | content: ''; 34 | position: absolute; 35 | top: calc(var(--arrow-size) * -0.5); 36 | left: calc(var(--arrow-size) * -0.5); 37 | border: calc(var(--arrow-size) * 0.5) solid transparent; 38 | } 39 | 40 | &::before { 41 | --arrow-size: 19px; 42 | --arrow-color: var(--P-dialog-outline); 43 | } 44 | 45 | &[data-type='left']::before, 46 | &[data-type='left']::after { 47 | border-right-color: var(--arrow-color); 48 | left: calc(var(--arrow-size) * -1); 49 | } 50 | &[data-type='right']::before, 51 | &[data-type='right']::after { 52 | border-left-color: var(--arrow-color); 53 | left: auto; 54 | right: calc(var(--arrow-size) * -1); 55 | } 56 | &[data-type='top']::before, 57 | &[data-type='top']::after { 58 | border-bottom-color: var(--arrow-color); 59 | top: calc(var(--arrow-size) * -1); 60 | } 61 | &[data-type='bottom']::before, 62 | &[data-type='bottom']::after { 63 | border-top-color: var(--arrow-color); 64 | top: calc(var(--arrow-size) * -0); 65 | } 66 | } 67 | 68 | > .inner-popover { 69 | position: absolute; 70 | background: var(--P-dialog-bg); 71 | box-shadow: 0 0.5em 1em rgba(0, 0, 0, 0.3), 0 0 0 1px var(--P-dialog-outline); 72 | border-radius: 0.5rem; 73 | overflow: hidden; 74 | 75 | > .i-content { 76 | position: absolute; 77 | top: 0; 78 | left: 0; 79 | max-width: calc(100vw - 2em); 80 | max-height: calc(100vh - 4em); 81 | overflow: hidden auto; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/uikit/form.css: -------------------------------------------------------------------------------- 1 | .uikit-form-container { 2 | .form-item { 3 | margin: 0.5em 0; 4 | background: rgb(var(--P-color-form-item-bg) / var(--P-color-form-item-bg-opacity)); 5 | border-radius: 0.5em; 6 | 7 | &:first-child { 8 | margin-top: 0; 9 | } 10 | &:last-child { 11 | margin-bottom: 0; 12 | } 13 | 14 | > .item-inner { 15 | display: flex; 16 | padding: 0.5em 1em; 17 | justify-content: space-between; 18 | align-items: center; 19 | flex-wrap: wrap; 20 | 21 | > .item-label { 22 | font-weight: 500; 23 | } 24 | } 25 | 26 | &.is-stacked .item-inner { 27 | display: block; 28 | 29 | > .item-label { 30 | margin-bottom: 0.2em; 31 | } 32 | 33 | > .item-contents > .uikit-text-field { 34 | min-width: 100%; 35 | } 36 | } 37 | 38 | > .item-description { 39 | padding: 0.5em 1em; 40 | padding-top: 0; 41 | font-size: small; 42 | opacity: 0.7; 43 | } 44 | } 45 | 46 | .form-description { 47 | margin: 0.5em; 48 | } 49 | 50 | .form-footer { 51 | display: flex; 52 | justify-content: space-between; 53 | margin: 0.5em; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/uikit/form.tsx: -------------------------------------------------------------------------------- 1 | import React, { createRef, PureComponent } from 'react'; 2 | import './form.css'; 3 | 4 | export class Form extends PureComponent { 5 | render() { 6 | return ( 7 |
    8 | {this.props.children} 9 |
    10 | ); 11 | } 12 | } 13 | 14 | namespace Form { 15 | export interface Props { 16 | className?: string; 17 | children: React.ReactNode; 18 | } 19 | } 20 | 21 | /** Renders a form item. Must be inside a
    ! */ 22 | export class FormItem extends PureComponent { 23 | innerNode = createRef(); 24 | contentsNode = createRef(); 25 | 26 | onItemClick = (e: React.MouseEvent) => { 27 | if (e.target !== this.innerNode.current) return; 28 | 29 | // clicked the background of the form item 30 | const contents = this.contentsNode.current; 31 | if (!contents) return; 32 | 33 | if (contents.children.length === 1) { 34 | // try forwarding to whatever input may be inside 35 | const input = contents.querySelector('input, select, button'); 36 | if (input) (input as HTMLElement).click(); 37 | } 38 | }; 39 | 40 | render() { 41 | const { stack, label, description, children, itemId } = this.props; 42 | let className = 'form-item'; 43 | if (stack) className += ' is-stacked'; 44 | 45 | let desc = null; 46 | if (description) { 47 | desc =
    {description}
    ; 48 | } 49 | 50 | return ( 51 |
    52 |
    53 |
    54 | 55 |
    56 | {stack && desc} 57 |
    58 | {children} 59 |
    60 |
    61 | {!stack && desc} 62 |
    63 | ); 64 | } 65 | } 66 | 67 | namespace FormItem { 68 | export interface Props { 69 | label: React.ReactNode; 70 | children: React.ReactNode; 71 | stack?: boolean; 72 | description?: React.ReactNode; 73 | itemId?: string; 74 | } 75 | } 76 | 77 | export class FormDescription extends PureComponent { 78 | render() { 79 | return
    {this.props.children}
    ; 80 | } 81 | } 82 | 83 | namespace FormDescription { 84 | export interface Props { 85 | children: React.ReactNode; 86 | } 87 | } 88 | 89 | export class FormFooter extends PureComponent { 90 | render() { 91 | return
    {this.props.children}
    ; 92 | } 93 | } 94 | 95 | namespace FormFooter { 96 | export interface Props { 97 | children: React.ReactNode; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/uikit/frame-animation.ts: -------------------------------------------------------------------------------- 1 | export interface AnimationTarget { 2 | update(dt: number): boolean | void; 3 | } 4 | 5 | export class AnimationController { 6 | targets = new Set(); 7 | running = false; 8 | animationId = 0; 9 | lastTime = 0; 10 | 11 | start() { 12 | this.running = true; 13 | const now = document.timeline.currentTime; 14 | if (!now) { 15 | setTimeout(() => this.start(), 1000); // try again later 16 | return; 17 | } 18 | this.lastTime = now; 19 | requestAnimationFrame(() => this.loop(++this.animationId)); 20 | } 21 | 22 | loop(animationId: number) { 23 | if (animationId !== this.animationId) return; 24 | 25 | const now = document.timeline.currentTime; 26 | if (!now) { 27 | setTimeout(() => this.loop(animationId), 1000); // try again later 28 | return; 29 | } 30 | const dt = (now - this.lastTime) / 1000; 31 | this.lastTime = now; 32 | 33 | const targetsToRemove = []; 34 | for (const target of this.targets) { 35 | const done = target.update(dt); 36 | if (done) targetsToRemove.push(target); 37 | } 38 | for (const target of targetsToRemove) this.remove(target); 39 | 40 | requestAnimationFrame(() => this.loop(animationId)); 41 | } 42 | 43 | stop() { 44 | this.animationId++; 45 | this.running = false; 46 | } 47 | 48 | add(target: AnimationTarget) { 49 | this.targets.add(target); 50 | if (!this.running) this.start(); 51 | } 52 | 53 | remove(target: AnimationTarget) { 54 | this.targets.delete(target); 55 | if (!this.targets.size) this.stop(); 56 | } 57 | } 58 | 59 | export class Spring { 60 | static FIXED_DT = 1 / 120; 61 | 62 | value: number; 63 | velocity: number; 64 | target: number; 65 | stiffness: number; 66 | damping: number; 67 | motionThreshold = 0.01; 68 | timeLeft = 0; 69 | 70 | constructor(init?: { 71 | value?: number; 72 | velocity?: number; 73 | target?: number; 74 | stiffness?: number; 75 | damping?: number; 76 | }) { 77 | this.value = init?.value || 0; 78 | this.velocity = init?.velocity || 0; 79 | this.target = Number.isFinite(init?.target) ? init!.target! : this.value; 80 | this.stiffness = init?.stiffness || 300; 81 | this.damping = init?.damping || 20; 82 | } 83 | 84 | update(dt: number) { 85 | if (Number.isNaN(this.value)) this.value = this.target; 86 | 87 | this.timeLeft += Math.max(0, Math.min(1, dt)); 88 | 89 | while (this.timeLeft > Spring.FIXED_DT) { 90 | this.timeLeft -= Spring.FIXED_DT; 91 | const force = 92 | -this.stiffness * (this.value - this.target) - this.damping * this.velocity; 93 | this.velocity += force * Spring.FIXED_DT; 94 | this.value += this.velocity * Spring.FIXED_DT; 95 | } 96 | 97 | const done = 98 | Math.abs(this.value - this.target) + Math.abs(this.velocity) < this.motionThreshold; 99 | if (done) { 100 | this.value = this.target; 101 | this.velocity = 0; 102 | } 103 | return done; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/uikit/layout-root-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const LayoutRootContext = createContext<{ current: HTMLElement | null }>({ 4 | current: document.body, 5 | }); 6 | -------------------------------------------------------------------------------- /src/uikit/text-field.css: -------------------------------------------------------------------------------- 1 | .uikit-text-field { 2 | display: inline-flex; 3 | width: 16em; 4 | max-width: 100%; 5 | background: rgb(var(--P-color-shade) / var(--P-color-shade-opacity)); 6 | border: 1px solid rgb(var(--P-color-soft-outline) / var(--P-color-soft-outline-opacity)); 7 | border-radius: 0.5em; 8 | box-sizing: border-box; 9 | 10 | .i-inner-field { 11 | margin: 0; 12 | background: none; 13 | font: inherit; 14 | color: inherit; 15 | padding: 0.5em 1em; 16 | border: none; 17 | width: 0; 18 | flex: 1; 19 | 20 | &::placeholder { 21 | color: rgb(var(--P-color-fg) / 0.5); 22 | } 23 | 24 | &:focus { 25 | outline: none; 26 | } 27 | } 28 | 29 | &.is-focused { 30 | box-shadow: 0 0 0 0.2em rgb(var(--P-color-accent) / 0.5); 31 | animation: uikit-text-field-focus-in 0.3s var(--P-ease-default); 32 | } 33 | 34 | &.is-narrow .i-inner-field { 35 | padding: 0.1em 0.5em; 36 | } 37 | } 38 | 39 | @media (prefers-reduced-motion: reduce) { 40 | .uikit-text-field.is-focused { 41 | animation: none; 42 | } 43 | } 44 | 45 | @keyframes uikit-text-field-focus-in { 46 | 0% { 47 | box-shadow: 0 0 0 1em rgb(var(--P-color-accent) / 0); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/uikit/text-field.tsx: -------------------------------------------------------------------------------- 1 | import { createRef, FocusEvent, InputHTMLAttributes, PureComponent } from 'react'; 2 | import './text-field.css'; 3 | 4 | export namespace TextField { 5 | export interface Props extends Omit, 'onChange'> { 6 | value: string; 7 | onChange: (v: string) => void; 8 | narrow?: boolean; 9 | } 10 | } 11 | export class TextField extends PureComponent { 12 | state = { 13 | focused: false, 14 | }; 15 | 16 | input = createRef(); 17 | 18 | onFocus = (e: FocusEvent) => { 19 | this.setState({ focused: true }); 20 | if (this.props.onFocus) this.props.onFocus(e); 21 | }; 22 | 23 | onBlur = (e: FocusEvent) => { 24 | this.setState({ focused: false }); 25 | if (this.props.onBlur) this.props.onBlur(e); 26 | }; 27 | 28 | focus() { 29 | this.input.current?.focus(); 30 | } 31 | 32 | blur() { 33 | this.input.current?.blur(); 34 | } 35 | 36 | render() { 37 | const { className: pClassName, value, onChange, ...extra } = this.props; 38 | let className = 'uikit-text-field '; 39 | if (this.state.focused) className += 'is-focused '; 40 | if (this.props.narrow) className += 'is-narrow '; 41 | className += pClassName || ''; 42 | 43 | return ( 44 | 45 | { 51 | onChange(e.target.value); 52 | }} 53 | onFocus={this.onFocus} 54 | onBlur={this.onBlur} 55 | className="i-inner-field" 56 | /> 57 | 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], 4 | "target": "ES2022", 5 | "strict": true, 6 | "module": "ES2022", 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src/**/*.tsx", "src/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import url from 'node:url'; 4 | import childProcess from 'node:child_process'; 5 | import { defineConfig } from 'vite'; 6 | import react from '@vitejs/plugin-react'; 7 | 8 | const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); 9 | const prod = process.env.NODE_ENV === 'production'; 10 | 11 | const CONFIG = { 12 | staticUrlPrefix: process.env.PRECHOSTER_STATIC || 'https://cohost.org/static/', 13 | gitCommitHash: process.env.PRECHOSTER_GIT_COMMIT_HASH?.substring(0, 7) || childProcess 14 | .execSync('git rev-parse --short HEAD', { 15 | cwd: __dirname, 16 | encoding: 'utf-8', 17 | }) 18 | .trim(), 19 | }; 20 | 21 | console.error(`\x1b[32mUsing PRECHOSTER_STATIC=${CONFIG.staticUrlPrefix}\x1b[m`); 22 | if (CONFIG.staticUrlPrefix.includes('//cohost.org')) { 23 | console.error('\x1b[31m+------------------------------------------------+\x1b[m'); 24 | console.error('\x1b[31m| loading assets directly from cohost dot org!!! |\x1b[m'); 25 | console.error('\x1b[31m| links may be unreliable... |\x1b[m'); 26 | console.error('\x1b[31m+------------------------------------------------+\x1b[m'); 27 | } 28 | 29 | export default defineConfig({ 30 | base: './', 31 | plugins: [react(), string(), stringNodeModules(), config(), hackToFixSvelteWebWorker()], 32 | build: { 33 | rollupOptions: { 34 | // i dont know why but some of these need to be repeated here for some reason 35 | plugins: [stringNodeModules(), hackToFixSvelteWebWorker()], 36 | }, 37 | sourcemap: true, 38 | }, 39 | }); 40 | 41 | function readFileAsModule(id) { 42 | return new Promise((resolve, reject) => { 43 | fs.readFile(id, 'utf-8', (err, file) => { 44 | if (err) reject(err); 45 | else { 46 | resolve(`export default ${JSON.stringify(file)};`); 47 | } 48 | }); 49 | }); 50 | } 51 | 52 | /** The `string:` loader can be used to load files as strings */ 53 | function string() { 54 | const scheme = 'string:'; 55 | 56 | return { 57 | name: 'string', 58 | resolveId(id, importer) { 59 | if (id.startsWith(scheme)) { 60 | const importerDir = path.dirname(importer) + '/'; 61 | const resolved = path.resolve(importerDir, id.substring(scheme.length)); 62 | return '\0' + scheme + resolved; 63 | } 64 | return null; 65 | }, 66 | load(id) { 67 | if (id.startsWith('\0' + scheme)) { 68 | return readFileAsModule(id.substring(scheme.length + 1)); 69 | } 70 | return null; 71 | }, 72 | }; 73 | } 74 | 75 | function stringNodeModules() { 76 | // don't let vite catch on that we're importing something from node modules 77 | const scheme = 'string-node-modules:'; 78 | 79 | return { 80 | name: 'string-node-modules', 81 | resolveId(id, importer) { 82 | if (id.startsWith(scheme)) { 83 | return '\0' + id; 84 | } 85 | return null; 86 | }, 87 | load(id) { 88 | if (id.startsWith('\0' + scheme)) { 89 | return readFileAsModule( 90 | __dirname + '/node_modules/' + id.substring(scheme.length + 1) 91 | ); 92 | } 93 | return null; 94 | }, 95 | }; 96 | } 97 | 98 | /** load build config from js */ 99 | function config() { 100 | const scheme = 'prechoster:'; 101 | 102 | return { 103 | name: 'config', 104 | resolveId(id, importer) { 105 | if (id.startsWith(scheme)) { 106 | return '\0' + id; 107 | } 108 | return null; 109 | }, 110 | load(id) { 111 | if (id.startsWith('\0' + scheme)) { 112 | const k = id.substring(scheme.length + 1); 113 | if (k === 'config') { 114 | return Object.keys(CONFIG) 115 | .map((k) => `export const ${k} = ${JSON.stringify(CONFIG[k])};`) 116 | .join('\n'); 117 | } 118 | } 119 | return null; 120 | }, 121 | }; 122 | } 123 | 124 | /** Fix svelte's incorrect use of `window`, which does not exist in web workers */ 125 | function hackToFixSvelteWebWorker() { 126 | return { 127 | transform(code, id) { 128 | if (id.includes('svelte/') || id.match(/node_modules.+svelte/)) { 129 | return { code: code.replace(/\bwindow\b/g, 'globalThis'), map: null }; 130 | } 131 | return null; 132 | }, 133 | enforce: 'post', 134 | }; 135 | } 136 | --------------------------------------------------------------------------------