├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── lib ├── client-bundle-plugin.mjs ├── css-plugin.mjs ├── emit-files-plugin.mjs ├── entry-data-plugin.mjs ├── entry-url-plugin.mjs ├── eval-plugin.mjs ├── move-output.mjs ├── node-external-plugin.mjs ├── omt.ejs ├── resolve-dirs-plugin.mjs ├── run-script.mjs ├── simple-ts.mjs ├── sw-plugin.mjs ├── url-plugin.mjs └── utils.mjs ├── missing-types.d.ts ├── notes.md ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── src ├── client │ ├── analytics │ │ ├── index.d.ts │ │ └── index.js │ ├── frame │ │ └── index.ts │ ├── main │ │ └── index.tsx │ └── missing-types.d.ts ├── copy │ ├── _headers │ └── _redirects ├── shared-types │ └── index.ts ├── shared │ ├── App │ │ ├── AnimatedDemos │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── CopyButton │ │ │ └── index.tsx │ │ ├── Editor │ │ │ ├── images │ │ │ │ └── error.svg │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── Graph │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── Header │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── Input │ │ │ └── index.tsx │ │ ├── Optim │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── Range │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── Select │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── columnsSignal.ts │ │ ├── demos.ts │ │ ├── index.tsx │ │ ├── styles.module.css │ │ ├── types.ts │ │ ├── useFriendlyLinearCode.ts │ │ ├── useFullPointGeneration │ │ │ ├── index.ts │ │ │ └── processEasing.ts │ │ ├── useLinearSyntax.ts │ │ ├── useOptimizedPoints.ts │ │ ├── useURLState.ts │ │ └── utils.ts │ └── missing-types.d.ts ├── static-build │ ├── assets │ │ ├── favicon.png │ │ ├── maskable-icon.png │ │ └── social-icon.png │ ├── index.tsx │ ├── missing-types.d.ts │ ├── pages │ │ ├── index │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ └── process-script │ │ │ └── index.tsx │ └── utils.tsx └── workers │ ├── missing-types.d.ts │ ├── process-script │ ├── error-stack-parser.ts │ └── index.ts │ └── sw │ ├── index.ts │ └── missing-types.d.ts ├── ts-configs ├── client-tsconfig.json ├── generic-tsconfig.json ├── static-build-tsconfig.json └── workers-tsconfig.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.local 4 | *.css.d.ts 5 | .tmp 6 | build 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakearchibald/linear-easing-generator/17e22b88db2a94e6a798610ce406a04c7f9b8d27/.prettierignore -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2024 Jake Archibald 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [linear() easing generator](https://linear-easing-generator.netlify.app/) 2 | 3 | For a long time, easing on the web was limited to `cubic-bezier`, meaning you couldn't do easings like bounce, spring, or elastic without JavaScript. But now `linear()` is here! Well, [almost here](https://caniuse.com/mdn-css_types_easing-function_linear-function). 4 | 5 | `linear()` works by defining a set of points. And, if you define enough points, you can create things that look and feel like curves. 6 | 7 | That's where the [linear() easing generator](https://linear-easing-generator.netlify.app/) comes in. It can convert JavaScript/SVG easing definitions into `linear()` format. 8 | 9 | [Check out the bounce demo](https://linear-easing-generator.netlify.app/?codeType=js&code=self.bounce+%3D+function%28pos%29+%7B%0A++const+n1+%3D+7.5625%3B%0A++const+d1+%3D+2.75%3B%0A%0A++if+%28pos+<+1+%2F+d1%29+%7B%0A++++return+n1+*+pos+*+pos%3B%0A++%7D+else+if+%28pos+<+2+%2F+d1%29+%7B%0A++++return+n1+*+%28pos+-%3D+1.5+%2F+d1%29+*+pos+%2B+0.75%3B%0A++%7D+else+if+%28pos+<+2.5+%2F+d1%29+%7B%0A++++return+n1+*+%28pos+-%3D+2.25+%2F+d1%29+*+pos+%2B+0.9375%3B%0A++%7D+else+%7B%0A++++return+n1+*+%28pos+-%3D+2.625+%2F+d1%29+*+pos+%2B+0.984375%3B%0A++%7D%0A%7D&simplify=0.0017&round=3). It gives you: 10 | 11 | ```css 12 | :root { 13 | --bounce-easing: linear( 14 | 0, 0.004, 0.016, 0.035, 0.063, 0.098, 0.141 13.6%, 0.25, 0.391, 0.563, 0.765, 15 | 1, 0.891 40.9%, 0.848, 0.813, 0.785, 0.766, 0.754, 0.75, 0.754, 0.766, 0.785, 16 | 0.813, 0.848, 0.891 68.2%, 1 72.7%, 0.973, 0.953, 0.941, 0.938, 0.941, 0.953, 17 | 0.973, 1, 0.988, 0.984, 0.988, 1 18 | ); 19 | } 20 | ``` 21 | 22 | Which you can use in animations and transitions: 23 | 24 | ```css 25 | .whatever { 26 | animation-timing-function: var(--bounce-easing); 27 | } 28 | ``` 29 | 30 | ## Running locally 31 | 32 | ``` 33 | npm i 34 | npm run dev 35 | ``` 36 | -------------------------------------------------------------------------------- /lib/client-bundle-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { rollup } from 'rollup'; 14 | import * as path from 'path'; 15 | 16 | const prefix = 'client-bundle:'; 17 | const entryPathPlaceholder = 'CLIENT_BUNDLE_PLUGIN_ENTRY_PATH'; 18 | const importsPlaceholder = 'CLIENT_BUNDLE_PLUGIN_IMPORTS'; 19 | const allSrcPlaceholder = 'CLIENT_BUNDLE_PLUGIN_ALL_SRC'; 20 | 21 | export function getDependencies(clientOutput, item) { 22 | const crawlDependencies = new Set([item.fileName]); 23 | const referencedFiles = new Set(); 24 | 25 | for (const fileName of crawlDependencies) { 26 | const chunk = clientOutput.find((v) => v.fileName === fileName); 27 | 28 | for (const dep of chunk.imports) { 29 | crawlDependencies.add(dep); 30 | } 31 | 32 | for (const dep of chunk.referencedFiles) { 33 | referencedFiles.add(dep); 34 | } 35 | } 36 | 37 | // Don't add self as dependency 38 | crawlDependencies.delete(item.fileName); 39 | 40 | // Merge referencedFiles as regular deps. They need to be in the same Set as 41 | // some JS files might appear in both lists and need to be deduped too. 42 | // 43 | // Didn't do this as part of the main loop since their `chunk` can't have 44 | // nested deps and sometimes might be missing altogether, depending on type. 45 | for (const dep of referencedFiles) { 46 | // This was only resulting in the service worker being included, so removing for now. 47 | //crawlDependencies.add(dep); 48 | } 49 | 50 | return [...crawlDependencies]; 51 | } 52 | 53 | export default function (inputOptions, outputOptions, resolveFileUrl) { 54 | let cache; 55 | let entryPointPlaceholderMap; 56 | let exportCounter; 57 | let clientBundle; 58 | let clientOutput; 59 | 60 | return { 61 | name: 'client-bundle', 62 | buildStart() { 63 | entryPointPlaceholderMap = new Map(); 64 | exportCounter = 0; 65 | }, 66 | async resolveId(id, importer) { 67 | if (!id.startsWith(prefix)) return null; 68 | 69 | const realId = id.slice(prefix.length); 70 | const resolveResult = await this.resolve(realId, importer, { 71 | // Tell the node-external plugin not to process this 72 | // TODO: This seems like the wrong way to handle this. 73 | // Instead, the inner rollup should pick up external references 74 | custom: { 'node-external': { skip: true } }, 75 | }); 76 | // Add an additional .js to the end so it ends up with .js at the end in the _virtual folder. 77 | if (resolveResult) return prefix + resolveResult.id + '.js'; 78 | // This Rollup couldn't resolve it, but maybe the inner one can. 79 | return id + '.js'; 80 | }, 81 | load(id) { 82 | if (!id.startsWith(prefix)) return; 83 | 84 | const realId = id.slice(prefix.length, -'.js'.length); 85 | 86 | exportCounter++; 87 | 88 | entryPointPlaceholderMap.set(exportCounter, realId); 89 | 90 | return [ 91 | `export default import.meta.${entryPathPlaceholder + exportCounter};`, 92 | `export const imports = import.meta.${ 93 | importsPlaceholder + exportCounter 94 | };`, 95 | `export const allSrc = import.meta.${ 96 | allSrcPlaceholder + exportCounter 97 | };`, 98 | ].join('\n'); 99 | }, 100 | async buildEnd(error) { 101 | const entryPoints = [...entryPointPlaceholderMap.values()]; 102 | // The static-build is done, so now we can perform our client build. 103 | // Exit early if there's nothing to build. 104 | if (error || entryPoints.length === 0) return; 105 | 106 | clientBundle = await rollup({ 107 | ...inputOptions, 108 | cache, 109 | input: entryPoints, 110 | }); 111 | 112 | cache = clientBundle.cache; 113 | }, 114 | async renderStart(staticBuildOutputOpts) { 115 | // The static-build has started generating output, so we can do the same for our client build. 116 | // Exit early if there's nothing to build. 117 | if (!clientBundle) return; 118 | const copiedOutputOptions = { 119 | assetFileNames: staticBuildOutputOpts.assetFileNames, 120 | }; 121 | clientOutput = ( 122 | await clientBundle.generate({ 123 | ...copiedOutputOptions, 124 | ...outputOptions, 125 | }) 126 | ).output; 127 | }, 128 | resolveImportMeta(property, { moduleId, format }) { 129 | // Pick up the placeholder exports we created earlier, and fill in the correct details. 130 | let num = undefined; 131 | 132 | if (property.startsWith(entryPathPlaceholder)) { 133 | num = Number(property.slice(entryPathPlaceholder.length)); 134 | } else if (property.startsWith(importsPlaceholder)) { 135 | num = Number(property.slice(importsPlaceholder.length)); 136 | } else if (property.startsWith(allSrcPlaceholder)) { 137 | num = Number(property.slice(allSrcPlaceholder.length)); 138 | } else { 139 | // This isn't one of our placeholders. 140 | return; 141 | } 142 | 143 | const id = path.normalize(entryPointPlaceholderMap.get(num)); 144 | const clientEntry = clientOutput.find( 145 | (item) => 146 | item.facadeModuleId && path.normalize(item.facadeModuleId) === id, 147 | ); 148 | 149 | if (property.startsWith(entryPathPlaceholder)) { 150 | return resolveFileUrl({ 151 | fileName: clientEntry.fileName, 152 | moduleId, 153 | format, 154 | }); 155 | } 156 | 157 | const dependencies = getDependencies(clientOutput, clientEntry); 158 | 159 | if (property.startsWith(allSrcPlaceholder)) { 160 | const allModules = [ 161 | clientEntry, 162 | ...dependencies 163 | .map((name) => clientOutput.find((item) => item.fileName === name)) 164 | .filter((item) => item.code), 165 | ]; 166 | 167 | const inlineDefines = [ 168 | ...allModules.map( 169 | (item) => 170 | `self.nextDefineUri=location.origin+${resolveFileUrl(item)};${ 171 | item.code 172 | }`, 173 | ), 174 | 'self.nextDefineUri=""', 175 | ]; 176 | 177 | return JSON.stringify(inlineDefines.join('')); 178 | } 179 | 180 | return ( 181 | '[' + 182 | dependencies 183 | .map((item) => { 184 | const entry = clientOutput.find((v) => v.fileName === item); 185 | 186 | return resolveFileUrl({ 187 | fileName: entry.fileName, 188 | moduleId, 189 | format: outputOptions.format, 190 | }); 191 | }) 192 | .join(',') + 193 | ']' 194 | ); 195 | }, 196 | async generateBundle(options, bundle) { 197 | // Exit early if there's nothing to build. 198 | if (!clientOutput) return; 199 | // Copy everything from the client bundle into the main bundle. 200 | for (const clientEntry of clientOutput) { 201 | // Skip if the file already exists 202 | if (clientEntry.fileName in bundle) continue; 203 | 204 | this.emitFile({ 205 | type: 'asset', 206 | source: clientEntry.code || clientEntry.source, 207 | fileName: clientEntry.fileName, 208 | }); 209 | } 210 | }, 211 | }; 212 | } 213 | -------------------------------------------------------------------------------- /lib/css-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { promises as fsp, readFileSync } from 'fs'; 14 | import { createHash } from 'crypto'; 15 | import { 16 | parse as parsePath, 17 | resolve as resolvePath, 18 | dirname, 19 | normalize as nomalizePath, 20 | join as joinPath, 21 | sep as pathSep, 22 | } from 'path'; 23 | 24 | import postcss from 'postcss'; 25 | import postCSSNested from 'postcss-nested'; 26 | import postCSSUrl from 'postcss-url'; 27 | import postCSSModules from 'postcss-modules'; 28 | import cssNano from 'cssnano'; 29 | import camelCase from 'lodash.camelcase'; 30 | import { glob } from 'glob'; 31 | 32 | const moduleSuffix = '.css'; 33 | const sourcePrefix = 'css:'; 34 | const addPrefix = 'add-css:'; 35 | const prerenderCSSModule = 'prerender-css:'; 36 | const assetRe = new RegExp('/fake/path/to/asset/([^/]+)/', 'g'); 37 | const sharedPath = joinPath(process.cwd(), 'src', 'shared') + pathSep; 38 | const staticBuildPath = 39 | joinPath(process.cwd(), 'src', 'static-build') + pathSep; 40 | 41 | const appendCssModule = '\0appendCss'; 42 | const appendCssSource = ` 43 | export default function appendCss(css) { 44 | if (__PRERENDER__) return; 45 | const style = document.createElement('style'); 46 | style.textContent = css; 47 | document.head.append(style); 48 | } 49 | `; 50 | 51 | export default function () { 52 | /** @type {Map} */ 53 | let hashToId; 54 | /** @type {Map} */ 55 | let pathToResult; 56 | 57 | return { 58 | name: 'css', 59 | async buildStart() { 60 | hashToId = new Map(); 61 | pathToResult = new Map(); 62 | 63 | const cssPaths = ( 64 | await glob('src/**/*.css', { 65 | nodir: true, 66 | absolute: true, 67 | }) 68 | ).map((cssPath) => 69 | // glob() returns windows paths with a forward slash. Normalise it: 70 | nomalizePath(cssPath), 71 | ); 72 | 73 | await Promise.all( 74 | cssPaths.map(async (path) => { 75 | this.addWatchFile(path); 76 | const file = await fsp.readFile(path); 77 | let moduleJSON; 78 | 79 | const cssResult = await postcss([ 80 | postCSSNested, 81 | postCSSModules({ 82 | getJSON(_, json) { 83 | moduleJSON = json; 84 | }, 85 | root: '', 86 | }), 87 | postCSSUrl({ 88 | url: ({ relativePath, url }) => { 89 | if (/^((https?|data):|#)/.test(url)) return url; 90 | const parsedPath = parsePath(relativePath); 91 | const source = readFileSync( 92 | resolvePath(dirname(path), relativePath), 93 | ); 94 | const fileId = this.emitFile({ 95 | type: 'asset', 96 | name: parsedPath.base, 97 | source, 98 | }); 99 | const hash = createHash('md5'); 100 | hash.update(source); 101 | const md5 = hash.digest('hex'); 102 | hashToId.set(md5, fileId); 103 | return `/fake/path/to/asset/${md5}/`; 104 | }, 105 | }), 106 | cssNano, 107 | ]).process(file, { 108 | from: path, 109 | }); 110 | 111 | const cssClassExports = Object.entries(moduleJSON).map( 112 | ([key, val]) => 113 | `export const ${camelCase(key)} = ${JSON.stringify(val)};`, 114 | ); 115 | 116 | const defs = 117 | '// This file is autogenerated by lib/css-plugin.js\n' + 118 | Object.keys(moduleJSON) 119 | .map((key) => `export const ${camelCase(key)}: string;`) 120 | .join('\n'); 121 | 122 | const defPath = path + '.d.ts'; 123 | const currentDefFileContent = await fsp 124 | .readFile(defPath, { encoding: 'utf8' }) 125 | .catch(() => undefined); 126 | 127 | // Only write the file if contents have changed, otherwise it causes a loop with 128 | // TypeScript's file watcher. 129 | if (defs !== currentDefFileContent) { 130 | await fsp.writeFile(defPath, defs); 131 | } 132 | 133 | pathToResult.set(path, { 134 | module: cssClassExports.join('\n'), 135 | css: cssResult.css, 136 | }); 137 | }), 138 | ); 139 | }, 140 | async resolveId(id, importer) { 141 | if (id === appendCssModule || id === prerenderCSSModule) return id; 142 | 143 | const prefix = id.startsWith(sourcePrefix) 144 | ? sourcePrefix 145 | : id.startsWith(addPrefix) 146 | ? addPrefix 147 | : undefined; 148 | 149 | if (!prefix) return; 150 | 151 | const resolved = await this.resolve(id.slice(prefix.length), importer); 152 | if (!resolved) throw Error(`Couldn't resolve ${id} from ${importer}`); 153 | 154 | return prefix + resolved.id; 155 | }, 156 | async load(id) { 157 | if (id === appendCssModule) return appendCssSource; 158 | if (id === prerenderCSSModule) { 159 | // This is a horrible hack, but gotta ship soon so… 160 | const prerenderResults = [...pathToResult] 161 | .filter( 162 | ([path]) => 163 | path.includes('/src/static-build/') || 164 | path.includes('/src/shared/'), 165 | ) 166 | .map(([_, result]) => result.css); 167 | 168 | return `export default ${JSON.stringify( 169 | prerenderResults.join(''), 170 | ).replace( 171 | assetRe, 172 | (match, hash) => 173 | `" + import.meta.ROLLUP_FILE_URL_${hashToId.get(hash)} + "`, 174 | )};`; 175 | } 176 | if (id.startsWith(sourcePrefix)) { 177 | const path = nomalizePath(id.slice(sourcePrefix.length)); 178 | 179 | if (!pathToResult.has(path)) { 180 | throw Error(`Cannot find ${path} in pathToResult`); 181 | } 182 | 183 | const cssStr = JSON.stringify(pathToResult.get(path).css).replace( 184 | assetRe, 185 | (match, hash) => 186 | `" + import.meta.ROLLUP_FILE_URL_${hashToId.get(hash)} + "`, 187 | ); 188 | 189 | return `export default ${cssStr};`; 190 | } 191 | if (id.startsWith(addPrefix)) { 192 | const path = nomalizePath(id.slice(addPrefix.length)); 193 | 194 | // If it's in the shared or static-build dirs, then just collect the CSS. 195 | // This can be imported using prerender-css: 196 | if (path.startsWith(sharedPath) || path.startsWith(staticBuildPath)) { 197 | return ''; 198 | } 199 | return ( 200 | `import css from ${JSON.stringify('css:' + path)};\n` + 201 | `import appendCss from '${appendCssModule}';\n` + 202 | `appendCss(css);\n` 203 | ); 204 | } 205 | if (id.endsWith(moduleSuffix)) { 206 | if (!pathToResult.has(id)) { 207 | throw Error(`Cannot find ${id} in pathToResult`); 208 | } 209 | 210 | return pathToResult.get(id).module; 211 | } 212 | }, 213 | }; 214 | } 215 | -------------------------------------------------------------------------------- /lib/emit-files-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import * as path from 'path'; 14 | import { promises as fs } from 'fs'; 15 | import { glob } from 'glob'; 16 | 17 | export default function emitFiles({ root, include }) { 18 | return { 19 | name: 'emit-files-plugin', 20 | async buildStart() { 21 | const paths = await glob(include, { nodir: true, cwd: root }); 22 | 23 | await Promise.all( 24 | paths.map(async (filePath) => { 25 | return this.emitFile({ 26 | type: 'asset', 27 | source: await fs.readFile(path.join(root, filePath)), 28 | fileName: 'static/' + filePath, 29 | }); 30 | }), 31 | ); 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /lib/entry-data-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { getDependencies } from './client-bundle-plugin.mjs'; 14 | import { fileNameToURL } from './utils.mjs'; 15 | import * as path from 'path'; 16 | 17 | const prefix = 'entry-data:'; 18 | const mainNamePlaceholder = 'ENTRY_DATA_PLUGIN_MAIN_NAME'; 19 | const dependenciesPlaceholder = 'ENTRY_DATA_PLUGIN_DEPS'; 20 | const placeholderRe = /(ENTRY_DATA_PLUGIN_(?:MAIN_NAME|DEPS))(\d+)/g; 21 | 22 | export default function entryDataPlugin() { 23 | /** @type {number} */ 24 | let exportCounter; 25 | /** @type {Map} */ 26 | let counterToIdMap; 27 | 28 | return { 29 | name: 'entry-data-plugin', 30 | buildStart() { 31 | exportCounter = 0; 32 | counterToIdMap = new Map(); 33 | }, 34 | async resolveId(id, importer) { 35 | if (!id.startsWith(prefix)) return; 36 | const realId = id.slice(prefix.length); 37 | const resolveResult = await this.resolve(realId, importer); 38 | 39 | if (!resolveResult) throw Error(`Cannot find ${realId}`); 40 | // Add an additional .js to the end so it ends up with .js at the end in the _virtual folder. 41 | return prefix + resolveResult.id + '.js'; 42 | }, 43 | load(id) { 44 | if (!id.startsWith(prefix)) return; 45 | const realId = id.slice(prefix.length, -'.js'.length); 46 | exportCounter++; 47 | 48 | counterToIdMap.set(exportCounter, path.normalize(realId)); 49 | 50 | return [ 51 | `export const main = ${mainNamePlaceholder + exportCounter};`, 52 | `export const deps = ${dependenciesPlaceholder + exportCounter};`, 53 | ].join('\n'); 54 | }, 55 | generateBundle(_, bundle) { 56 | const chunks = Object.values(bundle).filter( 57 | (item) => item.type === 'chunk', 58 | ); 59 | for (const chunk of chunks) { 60 | chunk.code = chunk.code.replace( 61 | placeholderRe, 62 | (_, placeholder, numStr) => { 63 | const id = counterToIdMap.get(Number(numStr)); 64 | const chunk = chunks.find( 65 | (chunk) => 66 | chunk.facadeModuleId && 67 | path.normalize(chunk.facadeModuleId) === id, 68 | ); 69 | if (!chunk) throw Error(`Cannot find ${id}`); 70 | 71 | if (placeholder === mainNamePlaceholder) { 72 | return JSON.stringify(fileNameToURL(chunk.fileName)); 73 | } 74 | 75 | return JSON.stringify( 76 | getDependencies(chunks, chunk).map((filename) => 77 | fileNameToURL(filename), 78 | ), 79 | ); 80 | }, 81 | ); 82 | } 83 | }, 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /lib/entry-url-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | const importPrefix = 'entry-url:'; 14 | 15 | export default function entryURLPlugin() { 16 | return { 17 | name: 'entry-url', 18 | async resolveId(id, importer) { 19 | if (!id.startsWith(importPrefix)) return; 20 | 21 | const plainId = id.slice(importPrefix.length); 22 | const result = await this.resolve(plainId, importer); 23 | if (!result) return; 24 | 25 | return importPrefix + result.id; 26 | }, 27 | load(id) { 28 | if (!id.startsWith(importPrefix)) return; 29 | 30 | return `export default import.meta.ROLLUP_FILE_URL_${this.emitFile({ 31 | type: 'chunk', 32 | id: id.slice(importPrefix.length), 33 | })};`; 34 | }, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /lib/eval-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | const importPrefix = 'eval:'; 14 | 15 | export default function evalPlugin() { 16 | return { 17 | name: 'eval', 18 | async resolveId(id, importer) { 19 | if (!id.startsWith(importPrefix)) return; 20 | 21 | const plainId = id.slice(importPrefix.length); 22 | const result = await this.resolve(plainId, importer); 23 | if (!result) return; 24 | 25 | return importPrefix + result.id; 26 | }, 27 | async load(id) { 28 | if (!id.startsWith(importPrefix)) return; 29 | 30 | const realId = id.slice(importPrefix.length); 31 | const module = await import(realId); 32 | let exportId = 0; 33 | 34 | return Object.entries(module) 35 | .map( 36 | ([name, value]) => 37 | `const export${exportId} = ${JSON.stringify( 38 | value, 39 | )}; export { export${exportId++} as ${name} };`, 40 | ) 41 | .join(''); 42 | }, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /lib/move-output.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | // Move .tmp/build/static to docs/ 14 | import { renameSync } from 'fs'; 15 | import { deleteAsync } from 'del'; 16 | import { join } from 'path'; 17 | 18 | await deleteAsync('build'); 19 | renameSync(join('.tmp', 'build', 'static'), 'build'); 20 | -------------------------------------------------------------------------------- /lib/node-external-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | // Check that a node module exists, and treat it as external. 14 | export default function () { 15 | return { 16 | name: 'node-external', 17 | async resolveId(id, importer, { custom }) { 18 | if ( 19 | id.startsWith('.') || 20 | id.startsWith('/') || 21 | custom['node-external']?.skip 22 | ) { 23 | return; 24 | } 25 | 26 | try { 27 | await import.meta.resolve(id); 28 | return { id, external: true }; 29 | } catch (err) {} 30 | }, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /lib/omt.ejs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2021 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | // If the loader is already loaded, just stop. 15 | if (!self.<%- amdFunctionName %>) { 16 | let registry = {}; 17 | 18 | const singleRequire = (uri, parentUri) => { 19 | uri = uri.startsWith(location.origin) ? uri : new URL(uri + ".js", parentUri).href; 20 | return registry[uri] || ( 21 | <% if (useEval) { %> 22 | fetch(uri) 23 | .then(resp => resp.text()) 24 | .then(code => { 25 | self.nextDefineUri = uri; 26 | eval(code); 27 | }) 28 | <% } else { %> 29 | new Promise(resolve => { 30 | if ("document" in self) { 31 | const link = document.createElement("link"); 32 | link.rel = "preload"; 33 | link.as = "script"; 34 | link.href = uri; 35 | link.onload = () => { 36 | const script = document.createElement("script"); 37 | script.src = uri; 38 | script.onload = resolve; 39 | document.head.appendChild(script); 40 | }; 41 | document.head.appendChild(link); 42 | } else { 43 | self.nextDefineUri = uri; 44 | importScripts(uri); 45 | resolve(); 46 | } 47 | }) 48 | <% } %> 49 | .then(() => { 50 | let promise = registry[uri]; 51 | if (!promise) { 52 | throw new Error(`Module ${uri} didn’t register its module`); 53 | } 54 | return promise; 55 | }) 56 | ); 57 | }; 58 | 59 | self.<%- amdFunctionName %> = (depsNames, factory) => { 60 | const uri = self.nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href; 61 | if (registry[uri]) { 62 | // Module is already loading or loaded. 63 | return; 64 | } 65 | let exports = {}; 66 | const require = depUri => singleRequire(depUri, uri); 67 | const specialDeps = { 68 | module: { uri }, 69 | exports, 70 | require 71 | }; 72 | // Note: Promise.resolve() is necessary to delay loading until all the 73 | // `define`s on the current page had a chance to execute first. 74 | // This allows to inline some deps on the main page. 75 | registry[uri] = Promise.resolve().then(() => Promise.all(depsNames.map( 76 | depName => specialDeps[depName] || require(depName) 77 | ))).then(deps => { 78 | factory(...deps); 79 | return exports; 80 | }); 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /lib/resolve-dirs-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { posix as pathUtils, isAbsolute } from 'path'; 14 | 15 | export default function resolveDirs(paths) { 16 | const pathBaseDir = paths.map((path) => [ 17 | pathUtils.basename(path), 18 | pathUtils.dirname(path), 19 | ]); 20 | 21 | return { 22 | name: 'resolve-dirs', 23 | async resolveId(id) { 24 | const match = pathBaseDir.find( 25 | ([pathId]) => id === pathId || id.startsWith(pathId + '/'), 26 | ); 27 | if (!match) return; 28 | const pathDir = match[1]; 29 | const resolveResult = await this.resolve(`./${pathDir}/${id}`, './'); 30 | if (!resolveResult) { 31 | throw new Error(`Couldn't find ${'./' + id}`); 32 | } 33 | if (isAbsolute(resolveResult.id)) return resolveResult.id; 34 | return pathUtils.resolve(resolveResult.id); 35 | }, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /lib/run-script.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { fork } from 'child_process'; 14 | 15 | export default function runScript(path) { 16 | return { 17 | name: 'run-script', 18 | writeBundle() { 19 | return new Promise((resolve, reject) => { 20 | const proc = fork(path, { 21 | stdio: 'inherit', 22 | }); 23 | 24 | proc.on('exit', (code) => { 25 | if (code !== 0) { 26 | reject(Error('Static build failed')); 27 | return; 28 | } 29 | resolve(); 30 | }); 31 | }); 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /lib/simple-ts.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { spawn } from 'child_process'; 14 | import { relative, join } from 'path'; 15 | import { promises as fsp } from 'fs'; 16 | 17 | import ts from 'typescript'; 18 | import { glob } from 'glob'; 19 | 20 | const extRe = /\.tsx?$/; 21 | 22 | function loadConfig(mainPath) { 23 | const fileName = ts.findConfigFile(mainPath, ts.sys.fileExists); 24 | if (!fileName) throw Error('tsconfig not found'); 25 | const text = ts.sys.readFile(fileName); 26 | const loadedConfig = ts.parseConfigFileTextToJson(fileName, text).config; 27 | const parsedTsConfig = ts.parseJsonConfigFileContent( 28 | loadedConfig, 29 | ts.sys, 30 | process.cwd(), 31 | undefined, 32 | fileName, 33 | ); 34 | return parsedTsConfig; 35 | } 36 | 37 | export default function simpleTS(mainPath, { noBuild, watch } = {}) { 38 | const config = loadConfig(mainPath); 39 | const args = ['-b', mainPath]; 40 | 41 | let tsBuildDone; 42 | 43 | async function watchBuiltFiles(rollupContext) { 44 | const matches = await glob(config.options.outDir + '/**/*.js'); 45 | for (const match of matches) rollupContext.addWatchFile(match); 46 | } 47 | 48 | async function tsBuild(rollupContext) { 49 | if (tsBuildDone) { 50 | // Watch lists are cleared on each build, so we need to rewatch all the JS files. 51 | await watchBuiltFiles(rollupContext); 52 | return tsBuildDone; 53 | } 54 | if (noBuild) { 55 | return (tsBuildDone = Promise.resolve()); 56 | } 57 | tsBuildDone = Promise.resolve().then(async () => { 58 | await new Promise((resolve) => { 59 | const proc = spawn('tsc', args, { 60 | stdio: 'inherit', 61 | }); 62 | 63 | proc.on('exit', (code) => { 64 | if (code !== 0) { 65 | throw Error('TypeScript build failed'); 66 | } 67 | resolve(); 68 | }); 69 | }); 70 | 71 | await watchBuiltFiles(rollupContext); 72 | 73 | if (watch) { 74 | tsBuildDone.then(() => { 75 | spawn('tsc', [...args, '--watch', '--preserveWatchOutput'], { 76 | stdio: 'inherit', 77 | }); 78 | }); 79 | } 80 | }); 81 | 82 | return tsBuildDone; 83 | } 84 | 85 | return { 86 | name: 'simple-ts', 87 | resolveId(id, importer) { 88 | // If there isn't an importer, it's an entry point, so we don't need to resolve it relative 89 | // to something. 90 | if (!importer) return null; 91 | 92 | const tsResolve = ts.resolveModuleName( 93 | id, 94 | importer, 95 | config.options, 96 | ts.sys, 97 | ); 98 | 99 | if ( 100 | // It didn't find anything 101 | !tsResolve.resolvedModule || 102 | // Or if it's linking to a definition file, it's something in node_modules, 103 | // or something local like css.d.ts 104 | tsResolve.resolvedModule.extension === '.d.ts' 105 | ) { 106 | return null; 107 | } 108 | return tsResolve.resolvedModule.resolvedFileName; 109 | }, 110 | async load(id) { 111 | if (!extRe.test(id)) return null; 112 | 113 | // TypeScript building is deferred until the first TS file load. 114 | // This allows prerequisites to happen first, 115 | // such as css.d.ts generation in css-plugin. 116 | await tsBuild(this); 117 | 118 | // Look for the JS equivalent in the tmp folder 119 | const newId = join( 120 | config.options.outDir, 121 | relative(process.cwd(), id), 122 | ).replace(extRe, '.js'); 123 | 124 | return fsp.readFile(newId, { encoding: 'utf8' }); 125 | }, 126 | }; 127 | } 128 | -------------------------------------------------------------------------------- /lib/sw-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { createHash } from 'crypto'; 14 | import { posix } from 'path'; 15 | 16 | const importPrefix = 'service-worker:'; 17 | 18 | export default function serviceWorkerPlugin({ 19 | output = 'sw.js', 20 | filterAssets = () => true, 21 | } = {}) { 22 | return { 23 | name: 'service-worker', 24 | async resolveId(id, importer) { 25 | if (!id.startsWith(importPrefix)) return; 26 | 27 | const plainId = id.slice(importPrefix.length); 28 | const result = await this.resolve(plainId, importer); 29 | if (!result) return; 30 | 31 | return importPrefix + result.id; 32 | }, 33 | load(id) { 34 | if (!id.startsWith(importPrefix)) return; 35 | const fileId = this.emitFile({ 36 | type: 'chunk', 37 | id: id.slice(importPrefix.length), 38 | fileName: output, 39 | }); 40 | 41 | return `export default import.meta.ROLLUP_FILE_URL_${fileId};`; 42 | }, 43 | generateBundle(options, bundle) { 44 | const swChunk = bundle[output]; 45 | 46 | if (!swChunk) return; 47 | 48 | const toCacheInSW = Object.values(bundle).filter( 49 | (item) => item !== swChunk && filterAssets(item), 50 | ); 51 | const urls = toCacheInSW.map( 52 | (item) => 53 | posix 54 | .relative(posix.dirname(output), item.fileName) 55 | .replace(/((?<=^|\/)index)?\.html?$/, '') || '.', 56 | ); 57 | 58 | const versionHash = createHash('sha1'); 59 | for (const item of toCacheInSW) { 60 | versionHash.update(item.code || item.source); 61 | } 62 | const version = versionHash.digest('hex'); 63 | 64 | swChunk.code = 65 | `const ASSETS = ${JSON.stringify(urls, null, ' ')};\n` + 66 | `const VERSION = ${JSON.stringify(version)};\n` + 67 | swChunk.code; 68 | }, 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /lib/url-plugin.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { promises as fs } from 'fs'; 14 | import { basename } from 'path'; 15 | import { imageSize } from 'image-size'; 16 | 17 | const defaultOpts = { 18 | prefix: 'url', 19 | imagePrefix: 'img-url', 20 | }; 21 | 22 | export default function urlPlugin(opts) { 23 | opts = Object.assign({}, defaultOpts, opts); 24 | 25 | /** @type {Map} */ 26 | let assetIdToSourceBuffer; 27 | 28 | const prefix = opts.prefix + ':'; 29 | const imagePrefix = opts.imagePrefix + ':'; 30 | return { 31 | name: 'url-plugin', 32 | buildStart() { 33 | assetIdToSourceBuffer = new Map(); 34 | }, 35 | augmentChunkHash(info) { 36 | // Get the sources for all assets imported by this chunk. 37 | const buffers = Object.keys(info.modules) 38 | .map((moduleId) => assetIdToSourceBuffer.get(moduleId)) 39 | .filter(Boolean); 40 | 41 | if (buffers.length === 0) return; 42 | 43 | for (const moduleId of Object.keys(info.modules)) { 44 | const buffer = assetIdToSourceBuffer.get(moduleId); 45 | if (buffer) buffers.push(buffer); 46 | } 47 | 48 | const combinedBuffer = 49 | buffers.length === 1 ? buffers[0] : Buffer.concat(buffers); 50 | 51 | return combinedBuffer; 52 | }, 53 | async resolveId(id, importer) { 54 | const idPrefix = [prefix, imagePrefix].find((prefix) => 55 | id.startsWith(prefix), 56 | ); 57 | if (!idPrefix) return; 58 | 59 | const realId = id.slice(idPrefix.length); 60 | const resolveResult = await this.resolve(realId, importer); 61 | 62 | if (!resolveResult) { 63 | throw Error(`Cannot find ${realId}`); 64 | } 65 | // Add an additional .js to the end so it ends up with .js at the end in the _virtual folder. 66 | return idPrefix + resolveResult.id + '.js'; 67 | }, 68 | async load(id) { 69 | const idPrefix = [prefix, imagePrefix].find((prefix) => 70 | id.startsWith(prefix), 71 | ); 72 | if (!idPrefix) return; 73 | 74 | const realId = id.slice(idPrefix.length, -'.js'.length); 75 | const source = await fs.readFile(realId); 76 | assetIdToSourceBuffer.set(id, source); 77 | this.addWatchFile(realId); 78 | 79 | let imgSizeExport = ''; 80 | 81 | if (idPrefix === imagePrefix) { 82 | const imgInfo = imageSize(source); 83 | imgSizeExport = [ 84 | `export const width = ${JSON.stringify(imgInfo.width)};`, 85 | `export const height = ${JSON.stringify(imgInfo.height)};`, 86 | ].join('\n'); 87 | } 88 | 89 | return [ 90 | `export default import.meta.ROLLUP_FILE_URL_${this.emitFile({ 91 | type: 'asset', 92 | source, 93 | name: basename(realId), 94 | })};`, 95 | imgSizeExport, 96 | ].join('\n'); 97 | }, 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /lib/utils.mjs: -------------------------------------------------------------------------------- 1 | /** @param {string} fileName */ 2 | export function fileNameToURL(fileName) { 3 | return fileName.replace(/^static\//, '/'); 4 | } 5 | -------------------------------------------------------------------------------- /missing-types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | declare module 'entry-data:*' { 14 | export const main: string; 15 | export const deps: string[]; 16 | } 17 | 18 | declare module 'url:*' { 19 | const value: string; 20 | export default value; 21 | } 22 | 23 | declare module 'img-url:*' { 24 | const value: string; 25 | export default value; 26 | export const width: number; 27 | export const height: number; 28 | } 29 | 30 | declare module 'css:*' { 31 | const source: string; 32 | export default source; 33 | } 34 | 35 | declare module 'service-worker:*' { 36 | const url: string; 37 | export default url; 38 | } 39 | 40 | declare module 'entry-url:*' { 41 | const url: string; 42 | export default url; 43 | } 44 | 45 | declare var ga: { 46 | (...args: any[]): void; 47 | q: any[]; 48 | }; 49 | 50 | declare const __PRODUCTION__: boolean; 51 | declare const __MAJOR_VERSION__: number; 52 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | TODOs: 2 | 3 | - Add duration to output? 4 | - Store duration in url? 5 | 6 | - Dark mode 7 | - Service worker 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linear-easing-generator", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "node --experimental-import-meta-resolve node_modules/.bin/rollup -c && node lib/move-output.mjs", 6 | "debug": "node --experimental-import-meta-resolve --inspect-brk node_modules/.bin/rollup -c", 7 | "dev": "node --experimental-import-meta-resolve node_modules/.bin/rollup -cw & npm run serve", 8 | "serve": "serve --cors .tmp/build/static" 9 | }, 10 | "devDependencies": { 11 | "@codemirror/lang-css": "^6.1.1", 12 | "@codemirror/lang-javascript": "^6.1.4", 13 | "@codemirror/theme-one-dark": "^6.1.1", 14 | "@preact/signals": "^1.1.3", 15 | "@rollup/plugin-commonjs": "^24.0.1", 16 | "@rollup/plugin-node-resolve": "^15.0.1", 17 | "@rollup/plugin-replace": "^5.0.2", 18 | "@rollup/plugin-terser": "^0.4.0", 19 | "@types/mime-types": "^2.1.1", 20 | "@types/node": "^18.11.18", 21 | "codemirror": "^6.0.1", 22 | "cssnano": "^5.0.17", 23 | "del": "^7.0.0", 24 | "glob": "^9.1.0", 25 | "image-size": "^1.0.1", 26 | "lodash.camelcase": "^4.3.0", 27 | "mime-types": "^2.1.34", 28 | "postcss": "^8.4.6", 29 | "postcss-modules": "^6.0.0", 30 | "postcss-nested": "^6.0.0", 31 | "postcss-url": "^10.1.3", 32 | "preact": "^10.6.5", 33 | "preact-render-to-string": "^5.1.19", 34 | "rollup": "^3.10.1", 35 | "serve": "^14.1.2", 36 | "svg-path-properties": "^1.1.0", 37 | "typescript": "^4.5.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import * as path from 'path'; 14 | import { fileURLToPath } from 'url'; 15 | 16 | import { deleteAsync } from 'del'; 17 | import resolve from '@rollup/plugin-node-resolve'; 18 | import commonjs from '@rollup/plugin-commonjs'; 19 | import terser from '@rollup/plugin-terser'; 20 | import replace from '@rollup/plugin-replace'; 21 | 22 | import simpleTS from './lib/simple-ts.mjs'; 23 | import { fileNameToURL } from './lib/utils.mjs'; 24 | import clientBundlePlugin from './lib/client-bundle-plugin.mjs'; 25 | import nodeExternalPlugin from './lib/node-external-plugin.mjs'; 26 | import urlPlugin from './lib/url-plugin.mjs'; 27 | import cssPlugin from './lib/css-plugin.mjs'; 28 | import resolveDirsPlugin from './lib/resolve-dirs-plugin.mjs'; 29 | import runScript from './lib/run-script.mjs'; 30 | import emitFiles from './lib/emit-files-plugin.mjs'; 31 | import serviceWorkerPlugin from './lib/sw-plugin.mjs'; 32 | import entryDataPlugin from './lib/entry-data-plugin.mjs'; 33 | import entryURLPlugin from './lib/entry-url-plugin.mjs'; 34 | import evalPlugin from './lib/eval-plugin.mjs'; 35 | 36 | import packageJSON from './package.json' assert { type: 'json' }; 37 | 38 | const __MAJOR_VERSION__ = Number(packageJSON.version.split('.')[0]); 39 | 40 | function resolveFileUrl({ fileName }) { 41 | return JSON.stringify(fileNameToURL(fileName)); 42 | } 43 | 44 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 45 | const dir = '.tmp/build'; 46 | const staticPath = 'static/c/[name]-[hash][extname]'; 47 | const jsPath = staticPath.replace('[extname]', '.js'); 48 | 49 | function jsFileName(chunkInfo) { 50 | if (!chunkInfo.facadeModuleId) return jsPath; 51 | const parsedPath = path.parse(chunkInfo.facadeModuleId); 52 | if (parsedPath.name !== 'index') return jsPath; 53 | // Come up with a better name than 'index' 54 | const name = parsedPath.dir.split(/\\|\//).slice(-1); 55 | return jsPath.replace('[name]', name); 56 | } 57 | 58 | export default async function ({ watch }) { 59 | await deleteAsync('.tmp/build'); 60 | 61 | const isProduction = !watch; 62 | 63 | const tsPluginInstance = simpleTS('.', { 64 | watch, 65 | }); 66 | 67 | const commonPlugins = () => [ 68 | tsPluginInstance, 69 | resolveDirsPlugin([ 70 | 'src/static-build', 71 | 'src/client', 72 | 'src/shared', 73 | 'src/workers', 74 | ]), 75 | urlPlugin(), 76 | cssPlugin(), 77 | ]; 78 | 79 | return { 80 | input: './src/static-build/index.tsx', 81 | output: { 82 | dir, 83 | format: 'cjs', 84 | assetFileNames: staticPath, 85 | exports: 'named', 86 | preserveModules: true, 87 | }, 88 | watch: { 89 | clearScreen: false, 90 | // Don't watch the ts files. Instead we watch the output from the ts compiler. 91 | exclude: ['**/*.ts', '**/*.tsx'], 92 | // Sometimes TypeScript does its thing a little slowly, which causes 93 | // Rollup to build twice on each change. This delay seems to fix it, 94 | // although we may need to change this number over time. 95 | buildDelay: 250, 96 | }, 97 | plugins: [ 98 | { resolveFileUrl }, 99 | clientBundlePlugin( 100 | { 101 | plugins: [ 102 | { resolveFileUrl }, 103 | serviceWorkerPlugin({ 104 | output: 'static/sw.js', 105 | }), 106 | entryURLPlugin(), 107 | ...commonPlugins(), 108 | evalPlugin(), 109 | commonjs(), 110 | resolve(), 111 | replace({ 112 | values: { 113 | __PRERENDER__: false, 114 | __PRODUCTION__: isProduction, 115 | __MAJOR_VERSION__, 116 | }, 117 | preventAssignment: true, 118 | }), 119 | entryDataPlugin(), 120 | isProduction ? terser({ module: true }) : {}, 121 | ], 122 | preserveEntrySignatures: false, 123 | }, 124 | { 125 | dir, 126 | format: 'esm', 127 | chunkFileNames: jsFileName, 128 | entryFileNames: jsFileName, 129 | }, 130 | resolveFileUrl, 131 | ), 132 | ...commonPlugins(), 133 | emitFiles({ include: '**/*', root: path.join(__dirname, 'src', 'copy') }), 134 | nodeExternalPlugin(), 135 | replace({ 136 | values: { 137 | __PRERENDER__: true, 138 | __PRODUCTION__: isProduction, 139 | __MAJOR_VERSION__, 140 | }, 141 | preventAssignment: true, 142 | }), 143 | runScript(dir + '/static-build/index.js'), 144 | ], 145 | }; 146 | } 147 | -------------------------------------------------------------------------------- /src/client/analytics/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | 14 | // This module exports nothing. 15 | export {}; 16 | -------------------------------------------------------------------------------- /src/client/analytics/index.js: -------------------------------------------------------------------------------- 1 | // From https://gist.github.com/idarek/9ade69ac2a2ef00d98ab950426af5791 2 | 3 | // Version 1.09.1.21062022 4 | 5 | // Changelog: 6 | // 1.09.1 7 | // - minor changes of single quote in code (') to double quote ("). 8 | // 1.09 9 | // - add listener for 90% scroll event. When user scrol to 90%+ of the page then script is fired again with scroll even and then (listener) terminates. 10 | // - Specified global parameters and small tweak in search event. 11 | // 1.08 12 | // - replaced VAR with LET and moved new URLSearchParams, as caused issues when minified 13 | // - changed Minified version from minify-js.com to go.tacodewolff.nl/minify as caused issues with higher number of new users than users 14 | // 1.07 15 | // - added event parameter (search_term) when tracking search via view_search_results event 16 | // -- commented gtmId as currently not in use, hidden from minified version 17 | // 1.06 18 | // - added event identification when page view is a search to set as view_search_results, in other case page_view 19 | // 1.05 20 | // - Added _fv (first_visit indicator) based on cid_v4 in local storage for identification of returning users 21 | // - Corrected (sr) screen resolution retection to include device Pixel ration (like Retina) 22 | // - gtm - value convert to lower case 23 | // - gtm - Google Tag Manager (GTM) Hash Info. If the current hit is coming was generated from GTM, it will contain a hash of current GTM/GTAG config 24 | // 1.04 25 | // - start new session (_ss) only when it is real new session (store session in sessionStorage); revert cid generation to previous method and store under cid_v4 to do not cause an issue when using minimal UA(v3) with this code. 26 | // 1.03 27 | // - split cid generation into two parts and combine at later stage 28 | // 1.02 29 | // - corrected generation of cid 30 | // 1.01 31 | // - changed method for generating _p, cid & sid 32 | // 1.00 33 | // - first release 34 | 35 | enScroll = false; 36 | const lStor = localStorage; 37 | const sStor = sessionStorage; 38 | const doc = document; 39 | const docEl = document.documentElement; 40 | const docBody = document.body; 41 | const docLoc = document.location; 42 | const w = window; 43 | const s = screen; 44 | const nav = navigator || {}; 45 | 46 | function a() { 47 | // debug options to clean cache 48 | // localStorage.clear(); 49 | // sessionStorage.clear(); 50 | 51 | const trackingId = 'G-HM9ZQSJVNZ'; // set here your Measurement ID for GA4 / Stream ID 52 | 53 | // gmt > If the current hit is coming was generated from GTM, it will contain a hash of current GTM/GTAG config, example: 2oear0 54 | // Currently not in use, leave XXXXXX , under investigation 55 | // let gtmId = "XXXXXX"; 56 | // if (gtmId == "XXXXXX") { 57 | // let gtmId = undefined; 58 | // } 59 | // else { 60 | // let gtmId = gtmId.toLowerCase(); 61 | // } 62 | 63 | // 10-ish digit number generator 64 | const generateId = () => Math.floor(Math.random() * 1000000000) + 1; 65 | // UNIX datetime generator 66 | const dategenId = () => Math.floor(Date.now() / 1000); 67 | 68 | const _pId = () => { 69 | if (!sStor._p) { 70 | sStor._p = generateId(); 71 | } 72 | return sStor._p; 73 | }; 74 | 75 | const generatecidId = () => generateId() + '.' + dategenId(); 76 | const cidId = () => { 77 | if (!lStor.cid_v4) { 78 | lStor.cid_v4 = generatecidId(); 79 | } 80 | return lStor.cid_v4; 81 | }; 82 | 83 | const cidCheck = lStor.getItem('cid_v4'); 84 | const _fvId = () => { 85 | if (cidCheck) { 86 | return undefined; 87 | } else if (enScroll == true) { 88 | return undefined; 89 | } else { 90 | return '1'; 91 | } 92 | }; 93 | 94 | const sidId = () => { 95 | if (!sStor.sid) { 96 | sStor.sid = dategenId(); 97 | } 98 | return sStor.sid; 99 | }; 100 | 101 | const _ssId = () => { 102 | if (!sStor._ss) { 103 | sStor._ss = '1'; 104 | return sStor._ss; 105 | } else if (sStor.getItem('_ss') == '1') { 106 | return undefined; 107 | } 108 | }; 109 | 110 | const generatesctId = '1'; 111 | const sctId = () => { 112 | if (!sStor.sct) { 113 | sStor.sct = generatesctId; 114 | } else if (enScroll == true) { 115 | return sStor.sct; 116 | } else { 117 | x = +sStor.getItem('sct') + +generatesctId; 118 | sStor.sct = x; 119 | } 120 | return sStor.sct; 121 | }; 122 | 123 | // Default GA4 Search Term Query Parameter: q,s,search,query,keyword 124 | const searchString = docLoc.search; 125 | const searchParams = new URLSearchParams(searchString); 126 | //const searchString = "?search1=test&query1=1234&s=dsf"; // test search string 127 | const sT = ['q', 's', 'search', 'query', 'keyword']; 128 | const sR = sT.some( 129 | (si) => 130 | searchString.includes('&' + si + '=') || 131 | searchString.includes('?' + si + '='), 132 | ); 133 | 134 | const eventId = () => { 135 | if (sR == true) { 136 | return 'view_search_results'; 137 | } else if (enScroll == true) { 138 | return 'scroll'; 139 | } else { 140 | return 'page_view'; 141 | } 142 | }; 143 | 144 | const eventParaId = () => { 145 | if (enScroll == true) { 146 | return '90'; 147 | } else { 148 | return undefined; 149 | } 150 | }; 151 | 152 | // get search_term 153 | const searchId = () => { 154 | if (eventId() == 'view_search_results') { 155 | //Iterate the search parameters. 156 | for (let p of searchParams) { 157 | //console.log(p); // for debuging 158 | if (sT.includes(p[0])) { 159 | return p[1]; 160 | } 161 | } 162 | } else { 163 | return undefined; 164 | } 165 | }; 166 | 167 | const encode = encodeURIComponent; 168 | const serialize = (obj) => { 169 | let str = []; 170 | for (let p in obj) { 171 | if (obj.hasOwnProperty(p)) { 172 | if (obj[p] !== undefined) { 173 | str.push(encode(p) + '=' + encode(obj[p])); 174 | } 175 | } 176 | } 177 | return str.join('&'); 178 | }; 179 | 180 | const debug = false; // enable analytics debuging 181 | 182 | // url 183 | const url = 'https://www.google-analytics.com/g/collect'; 184 | // payload 185 | const data = serialize({ 186 | v: '2', // Measurement Protocol Version 2 for GA4 187 | tid: trackingId, // Measurement ID for GA4 or Stream ID 188 | //gtm: gtmId, // Google Tag Manager (GTM) Hash Info. If the current hit is coming was generated from GTM, it will contain a hash of current GTM/GTAG config (not in use, currently under investigation) 189 | _p: _pId(), // random number, hold in sessionStorage, unknown use 190 | sr: ( 191 | s.width * w.devicePixelRatio + 192 | 'x' + 193 | s.height * w.devicePixelRatio 194 | ).toString(), // Screen Resolution 195 | ul: (nav.language || undefined).toLowerCase(), // User Language 196 | cid: cidId(), // client ID, hold in localStorage 197 | _fv: _fvId(), // first_visit, identify returning users based on existance of client ID in localStorage 198 | _s: '1', // session hits counter 199 | dl: docLoc.origin + docLoc.pathname + searchString, // Document location 200 | dt: doc.title || undefined, // document title 201 | dr: doc.referrer || undefined, // document referrer 202 | sid: sidId(), // session ID random generated, hold in sessionStorage 203 | sct: sctId(), // session count for a user, increase +1 in new interaction 204 | seg: '1', // session engaged (interacted for at least 10 seconds), assume yes 205 | en: eventId(), // event like page_view, view_search_results or scroll 206 | 'epn.percent_scrolled': eventParaId(), // event parameter, used for scroll event 207 | 'ep.search_term': searchId(), // search_term reported for view_search_results from search parameter 208 | _ss: _ssId(), // session_start, new session start 209 | _dbg: debug ? 1 : undefined, // console debug 210 | }); 211 | 212 | const fullurl = url + '?' + data; 213 | 214 | // for debug purposes 215 | // console.log(data); 216 | // console.log(url, data); 217 | // console.log(fullurl); 218 | 219 | if (nav.sendBeacon) { 220 | nav.sendBeacon(fullurl); 221 | } else { 222 | let xhr = new XMLHttpRequest(); 223 | xhr.open('POST', fullurl, true); 224 | } 225 | } 226 | a(); 227 | 228 | // Scroll Percent 229 | function sPr() { 230 | return ( 231 | ((docEl.scrollTop || docBody.scrollTop) / 232 | ((docEl.scrollHeight || docBody.scrollHeight) - docEl.clientHeight)) * 233 | 100 234 | ); 235 | } 236 | // add scroll listener 237 | doc.addEventListener('scroll', sEv, { passive: true }); 238 | 239 | // scroll Event 240 | function sEv() { 241 | const percentage = sPr(); 242 | 243 | if (percentage < 90) { 244 | return; 245 | } 246 | enScroll = true; 247 | // fire analytics script 248 | a(); 249 | // remove scroll listener 250 | doc.removeEventListener('scroll', sEv, { passive: true }); 251 | } 252 | -------------------------------------------------------------------------------- /src/client/frame/index.ts: -------------------------------------------------------------------------------- 1 | import workerURL from 'entry-url:workers/process-script'; 2 | import { ProcessScriptData, PostMessageError } from 'shared-types/index'; 3 | 4 | // Null origins can't create workers from an external resource, 5 | // so we need to fetch the script and create a blob URL. 6 | const localWorkerURL = (async () => { 7 | const response = await fetch(workerURL); 8 | const text = await response.text(); 9 | return URL.createObjectURL(new Blob([text], { type: 'text/javascript' })); 10 | })(); 11 | 12 | function isProcessJSData(data: any): data is ProcessScriptData { 13 | return data.action === 'process-script'; 14 | } 15 | 16 | function isProcessSVGData(data: any): data is ProcessScriptData { 17 | return data.action === 'process-svg'; 18 | } 19 | 20 | function isTerminateWorkerAction( 21 | data: any, 22 | ): data is { action: 'terminate-worker' } { 23 | return data.action === 'terminate-worker'; 24 | } 25 | 26 | let workerPromise: Promise | null = null; 27 | 28 | function startWorker() { 29 | if (workerPromise) throw Error('Worker already running'); 30 | workerPromise = localWorkerURL.then((url) => new Worker(url)); 31 | } 32 | 33 | async function terminateWorker() { 34 | if (!workerPromise) return; 35 | const lastWorkerPromise = workerPromise; 36 | workerPromise = null; 37 | 38 | (await lastWorkerPromise).terminate(); 39 | } 40 | 41 | onmessage = async ({ data }) => { 42 | if (typeof data !== 'object' || data === null) return; 43 | 44 | if (isProcessJSData(data) || isProcessSVGData(data)) { 45 | // Throw if worker is already running. 46 | // This action needs a fresh worker, as user script may break it. 47 | try { 48 | startWorker(); 49 | } catch (error) { 50 | const postMessageError: PostMessageError = { 51 | message: (error as Error).message, 52 | }; 53 | data.port.postMessage({ error: postMessageError }); 54 | throw error; 55 | } 56 | 57 | // Also send the port to the worker, so the result can be sent straight back to the page, 58 | // Rather than going via this page. 59 | (await workerPromise!).postMessage(data, [data.port]); 60 | } else if (isTerminateWorkerAction(data)) { 61 | terminateWorker(); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/client/main/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import App from 'shared/App'; 3 | 4 | render(, document.getElementById('app')!); 5 | -------------------------------------------------------------------------------- /src/client/missing-types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'eval:*' { 2 | const val: unknown; 3 | export default val; 4 | } 5 | -------------------------------------------------------------------------------- /src/copy/_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Cache-Control: no-cache 3 | Access-Control-Allow-Origin: * 4 | /c/* 5 | Cache-Control: max-age=31536000 6 | -------------------------------------------------------------------------------- /src/copy/_redirects: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakearchibald/linear-easing-generator/17e22b88db2a94e6a798610ce406a04c7f9b8d27/src/copy/_redirects -------------------------------------------------------------------------------- /src/shared-types/index.ts: -------------------------------------------------------------------------------- 1 | export interface ProcessScriptData { 2 | action: 'process-script'; 3 | script: string; 4 | port: MessagePort; 5 | } 6 | 7 | export interface ProcessSVGData { 8 | action: 'process-svg'; 9 | script: string; 10 | port: MessagePort; 11 | } 12 | 13 | export interface ProcessResult { 14 | name: string; 15 | points: LinearData; 16 | duration: number; 17 | } 18 | 19 | export type LinearData = [pos: number, val: number][]; 20 | 21 | export interface BasicStackDetails { 22 | functionName: string; 23 | } 24 | 25 | export interface FullStackDetails extends BasicStackDetails { 26 | fileName: string; 27 | lineNumber: number; 28 | columnNumber: number; 29 | } 30 | 31 | export type StackDetails = BasicStackDetails | FullStackDetails; 32 | type ErrorObj = { message: string }; 33 | export type PostMessageError = (StackDetails & ErrorObj) | ErrorObj; 34 | -------------------------------------------------------------------------------- /src/shared/App/AnimatedDemos/index.tsx: -------------------------------------------------------------------------------- 1 | import { Signal, useComputed, useSignalEffect } from '@preact/signals'; 2 | import { h, Fragment, RenderableProps, FunctionComponent } from 'preact'; 3 | import 'add-css:./styles.module.css'; 4 | import * as styles from './styles.module.css'; 5 | import { useEffect, useRef } from 'preact/hooks'; 6 | 7 | interface Props { 8 | linear: Signal; 9 | play: Signal; 10 | slightlyOptimizedLinear: Signal; 11 | duration: Signal; 12 | } 13 | 14 | const linearSupported = __PRERENDER__ 15 | ? true 16 | : CSS.supports('animation-timing-function', 'linear(0, 1)'); 17 | 18 | const gap = 500; 19 | 20 | const anim = ( 21 | el: HTMLElement, 22 | easing: string, 23 | duration: number, 24 | keyframes: Keyframe[] | PropertyIndexedKeyframes, 25 | ) => 26 | el.animate(keyframes, { 27 | fill: 'forwards', 28 | duration, 29 | easing: easing || 'linear', 30 | }); 31 | 32 | const useLinearValue = (linear: Signal) => 33 | useComputed(() => 34 | linear.value.length === 0 ? '' : `linear(${linear.value.join()})`, 35 | ); 36 | 37 | const Demos: FunctionComponent = ({ 38 | slightlyOptimizedLinear, 39 | linear, 40 | duration, 41 | play, 42 | }: RenderableProps) => { 43 | const slightlyOptimizedLinearStr = useLinearValue(slightlyOptimizedLinear); 44 | const linearStr = useLinearValue(linear); 45 | const computedPlayState = useComputed(() => 46 | Boolean(play.value && slightlyOptimizedLinearStr.value && linearStr.value), 47 | ); 48 | const demos = useRef(null); 49 | const slightlyOptimizedTranslateEl = useRef(null); 50 | const translateEl = useRef(null); 51 | 52 | // Initial setup 53 | useEffect(() => { 54 | let stop = false; 55 | 56 | // Watch for the media change, as the animation direction changes 57 | const horizonalAnimMedia = matchMedia( 58 | '(min-width: 1820px), (max-width: 1159px)', 59 | ); 60 | let currentMediaListener: (() => void) | null = null; 61 | const mediaListener = () => currentMediaListener?.(); 62 | horizonalAnimMedia.addEventListener('change', mediaListener); 63 | 64 | const getAxis = () => (horizonalAnimMedia.matches ? 'X' : 'Y'); 65 | const getOutKeyframes = () => [ 66 | `translate${getAxis()}(0)`, 67 | `translate${getAxis()}(100%)`, 68 | ]; 69 | const getInKeyframes = () => [ 70 | `translate${getAxis()}(100%)`, 71 | `translate${getAxis()}(0)`, 72 | ]; 73 | 74 | (async () => { 75 | if (!linearSupported) return; 76 | 77 | while (!stop) { 78 | const outAnims = [ 79 | anim( 80 | slightlyOptimizedTranslateEl.current!, 81 | slightlyOptimizedLinearStr.value, 82 | duration.value, 83 | { 84 | transform: getOutKeyframes(), 85 | }, 86 | ), 87 | anim(translateEl.current!, linearStr.value, duration.value, { 88 | transform: getOutKeyframes(), 89 | }), 90 | ]; 91 | 92 | // Switch the keyframes if the media query changes 93 | currentMediaListener = () => { 94 | for (const anim of outAnims) { 95 | (anim.effect as KeyframeEffect).setKeyframes({ 96 | transform: getOutKeyframes(), 97 | }); 98 | } 99 | }; 100 | 101 | await Promise.all(outAnims.map((a) => a.finished)); 102 | await slightlyOptimizedTranslateEl.current!.animate(null, gap).finished; 103 | 104 | if (stop) break; 105 | 106 | const inAnims = [ 107 | anim( 108 | slightlyOptimizedTranslateEl.current!, 109 | slightlyOptimizedLinearStr.value, 110 | duration.value, 111 | { 112 | transform: getInKeyframes(), 113 | }, 114 | ), 115 | anim(translateEl.current!, linearStr.value, duration.value, { 116 | transform: getInKeyframes(), 117 | }), 118 | ]; 119 | 120 | // Switch the keyframes if the media query changes 121 | currentMediaListener = () => { 122 | for (const anim of outAnims) { 123 | (anim.effect as KeyframeEffect).setKeyframes({ 124 | transform: getInKeyframes(), 125 | }); 126 | } 127 | }; 128 | 129 | await Promise.all(inAnims.map((a) => a.finished)); 130 | await slightlyOptimizedTranslateEl.current!.animate(null, gap).finished; 131 | } 132 | })(); 133 | 134 | return () => { 135 | stop = true; 136 | horizonalAnimMedia.removeEventListener('change', mediaListener); 137 | }; 138 | }, []); 139 | 140 | // Updates to easing 141 | useSignalEffect(() => { 142 | if ( 143 | slightlyOptimizedLinearStr.value === '' || 144 | linearStr.value === '' || 145 | !linearSupported 146 | ) { 147 | return; 148 | } 149 | 150 | const slightly = [slightlyOptimizedTranslateEl.current!].flatMap((el) => 151 | el.getAnimations(), 152 | ); 153 | 154 | const optim = [translateEl.current!].flatMap((el) => el.getAnimations()); 155 | 156 | for (const anim of slightly) { 157 | anim.effect!.updateTiming({ easing: slightlyOptimizedLinearStr.value }); 158 | } 159 | 160 | for (const anim of optim) { 161 | anim.effect!.updateTiming({ easing: linearStr.value }); 162 | } 163 | }); 164 | 165 | // Updates to duration 166 | useSignalEffect(() => { 167 | if ( 168 | slightlyOptimizedLinearStr.value === '' || 169 | linearStr.value === '' || 170 | !linearSupported 171 | ) { 172 | return; 173 | } 174 | 175 | const anims = [ 176 | slightlyOptimizedTranslateEl.current!, 177 | translateEl.current!, 178 | ].flatMap((el) => el.getAnimations()); 179 | 180 | for (const anim of anims) { 181 | anim.effect!.updateTiming({ duration: duration.value }); 182 | } 183 | }); 184 | 185 | // Changes to play state 186 | useSignalEffect(() => { 187 | const anims = [ 188 | slightlyOptimizedTranslateEl.current!, 189 | translateEl.current!, 190 | ].flatMap((el) => el.getAnimations()); 191 | 192 | for (const anim of anims) { 193 | if (anim.playState === 'finished') continue; 194 | 195 | if (computedPlayState.value) { 196 | anim.play(); 197 | } else { 198 | anim.pause(); 199 | } 200 | } 201 | }); 202 | 203 | return ( 204 |
205 |
206 |
207 |
208 | Input 209 |
210 |
211 |
212 |
Output
213 |
214 |
215 | {!linearSupported && ( 216 |
217 |
218 | linear() not supported by this browser.{' '} 219 | 223 | See browser support. 224 | 225 |
226 |
227 | )} 228 |
229 | ); 230 | }; 231 | 232 | export { Demos as default }; 233 | -------------------------------------------------------------------------------- /src/shared/App/AnimatedDemos/styles.module.css: -------------------------------------------------------------------------------- 1 | .demos { 2 | --demo-box-size: 65px; 3 | overflow: hidden; 4 | background: var(--surface-1); 5 | display: grid; 6 | align-items: center; 7 | position: relative; 8 | } 9 | 10 | .demo-box { 11 | width: var(--demo-box-size); 12 | height: var(--demo-box-size); 13 | background-color: var(--text-2); 14 | border-radius: 1000px; 15 | display: grid; 16 | align-content: center; 17 | justify-content: center; 18 | color: var(--surface-1); 19 | } 20 | 21 | .unoptimized-demo-box { 22 | background-color: var(--accent-2); 23 | } 24 | 25 | .translate { 26 | height: 75%; 27 | display: grid; 28 | grid-template-columns: 1fr 1fr; 29 | gap: 20px; 30 | padding: 0 20px; 31 | 32 | @media (min-width: 1820px), (max-width: 1159px) { 33 | grid-template-columns: none; 34 | grid-template-rows: 1fr 1fr; 35 | } 36 | } 37 | 38 | .translate > div { 39 | margin-bottom: var(--demo-box-size); 40 | 41 | @media (min-width: 1820px), (max-width: 1159px) { 42 | margin-bottom: 0; 43 | margin-right: var(--demo-box-size); 44 | } 45 | } 46 | 47 | .unsupported-message { 48 | position: absolute; 49 | inset: 0; 50 | display: grid; 51 | align-content: center; 52 | justify-content: center; 53 | text-align: center; 54 | background: var(--surface-1); 55 | font-size: 0.9rem; 56 | padding: 10px; 57 | 58 | a { 59 | display: block; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/shared/App/CopyButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Signal } from '@preact/signals'; 2 | import { h, RenderableProps, FunctionComponent } from 'preact'; 3 | import * as sharedStyles from '../styles.module.css'; 4 | import { useRef } from 'preact/hooks'; 5 | import { animateFrom, hideFromPrerender } from '../utils'; 6 | 7 | interface Props { 8 | value: Signal; 9 | } 10 | 11 | const CopyButton: FunctionComponent = ({ 12 | value, 13 | }: RenderableProps) => { 14 | const buttonRef = useRef(null); 15 | 16 | return ( 17 | 35 | ); 36 | }; 37 | 38 | export { CopyButton as default }; 39 | -------------------------------------------------------------------------------- /src/shared/App/Editor/images/error.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/shared/App/Editor/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, RenderableProps, FunctionComponent } from 'preact'; 2 | import { useRef, useLayoutEffect, useEffect } from 'preact/hooks'; 3 | import { EditorView } from 'codemirror'; 4 | import { javascript } from '@codemirror/lang-javascript'; 5 | import { cssLanguage } from '@codemirror/lang-css'; 6 | import { oneDark } from '@codemirror/theme-one-dark'; 7 | import { EditorState, Compartment } from '@codemirror/state'; 8 | import { 9 | ReadonlySignal, 10 | signal, 11 | useComputed, 12 | useSignalEffect, 13 | } from '@preact/signals'; 14 | import { CodeHighlight } from '../types'; 15 | import 'add-css:./styles.module.css'; 16 | import * as styles from './styles.module.css'; 17 | 18 | import { 19 | lineNumbers, 20 | highlightActiveLineGutter, 21 | highlightSpecialChars, 22 | highlightActiveLine, 23 | keymap, 24 | drawSelection, 25 | ViewUpdate, 26 | } from '@codemirror/view'; 27 | import { indentOnInput, bracketMatching } from '@codemirror/language'; 28 | import { 29 | history, 30 | defaultKeymap, 31 | historyKeymap, 32 | indentWithTab, 33 | } from '@codemirror/commands'; 34 | import { 35 | closeBrackets, 36 | autocompletion, 37 | closeBracketsKeymap, 38 | completionKeymap, 39 | } from '@codemirror/autocomplete'; 40 | 41 | const extensions = () => [ 42 | lineNumbers(), 43 | highlightActiveLineGutter(), 44 | highlightSpecialChars(), 45 | history(), 46 | EditorState.allowMultipleSelections.of(true), 47 | indentOnInput(), 48 | bracketMatching(), 49 | closeBrackets(), 50 | autocompletion(), 51 | highlightActiveLine(), 52 | drawSelection(), 53 | keymap.of([ 54 | ...closeBracketsKeymap, 55 | ...defaultKeymap, 56 | ...historyKeymap, 57 | ...completionKeymap, 58 | indentWithTab, 59 | ]), 60 | oneDark, 61 | ]; 62 | 63 | interface Props { 64 | code: ReadonlySignal; 65 | onInput?: (value: string) => void; 66 | error?: ReadonlySignal; 67 | language: ReadonlySignal; 68 | readOnly?: boolean; 69 | } 70 | 71 | const highlighting = { 72 | [CodeHighlight.JS]: javascript, 73 | [CodeHighlight.CSS]: () => cssLanguage, 74 | [CodeHighlight.SVG]: () => [], 75 | } as const; 76 | 77 | const Editor: FunctionComponent = ({ 78 | code, 79 | onInput, 80 | error = signal(''), 81 | language, 82 | readOnly, 83 | }: RenderableProps) => { 84 | const editorContainerRef = useRef(null); 85 | const editorViewRef = useRef(null); 86 | const lastPropValueRef = useRef(''); 87 | const onInputRef = useRef(undefined); 88 | const languageCompartment = useRef(null); 89 | const readOnlyCompartment = useRef(null); 90 | 91 | useEffect(() => { 92 | onInputRef.current = onInput; 93 | }, [onInput]); 94 | 95 | // Initial setup of the editor 96 | useLayoutEffect(() => { 97 | // Handle updates to the editor 98 | const updateListener = EditorView.updateListener.of( 99 | (update: ViewUpdate) => { 100 | if (!update.docChanged) return; 101 | const newValue = update.state.doc.toString(); 102 | if (newValue === lastPropValueRef.current) return; 103 | lastPropValueRef.current = newValue; 104 | onInputRef.current?.(newValue); 105 | }, 106 | ); 107 | 108 | languageCompartment.current = new Compartment(); 109 | readOnlyCompartment.current = new Compartment(); 110 | 111 | editorViewRef.current = new EditorView({ 112 | extensions: [ 113 | ...extensions(), 114 | languageCompartment.current.of([]), 115 | readOnlyCompartment.current.of([]), 116 | updateListener, 117 | ], 118 | parent: editorContainerRef.current!, 119 | }); 120 | 121 | return () => { 122 | editorViewRef.current!.destroy(); 123 | }; 124 | }, []); 125 | 126 | // Handle changes to the value prop 127 | useSignalEffect(() => { 128 | if (code.value === lastPropValueRef.current) return; 129 | 130 | const currentLength = lastPropValueRef.current.length; 131 | 132 | lastPropValueRef.current = code.value; 133 | 134 | editorViewRef.current!.dispatch({ 135 | changes: { 136 | from: 0, 137 | to: currentLength, 138 | insert: code.value, 139 | }, 140 | }); 141 | }); 142 | 143 | useSignalEffect(() => { 144 | editorViewRef.current!.dispatch({ 145 | effects: languageCompartment.current!.reconfigure( 146 | highlighting[language.value](), 147 | ), 148 | }); 149 | }); 150 | 151 | useEffect(() => { 152 | editorViewRef.current!.dispatch({ 153 | effects: readOnlyCompartment.current!.reconfigure( 154 | EditorState.readOnly.of(readOnly || false), 155 | ), 156 | }); 157 | }, [readOnly]); 158 | 159 | const errorStyle = useComputed(() => (error.value ? '' : 'display: none')); 160 | 161 | return ( 162 |
163 |
164 |
165 | {error} 166 |
167 |
168 | ); 169 | }; 170 | 171 | export { Editor as default }; 172 | -------------------------------------------------------------------------------- /src/shared/App/Editor/styles.module.css: -------------------------------------------------------------------------------- 1 | .component { 2 | display: grid; 3 | grid-template-rows: 1fr auto; 4 | overflow: hidden; 5 | background: var(--editor-background); 6 | } 7 | 8 | .editor-container { 9 | display: grid; 10 | grid-template-columns: 100%; 11 | overflow: hidden; 12 | 13 | & :global(.cm-editor) { 14 | overflow: hidden; 15 | } 16 | } 17 | 18 | .error { 19 | background: var(--error-background); 20 | border-top: 1px solid var(--error-border); 21 | color: var(--error-text); 22 | display: grid; 23 | font-family: menlo, consolas, lucida console, courier new, dejavu sans mono, 24 | monospace; 25 | font-size: 13px; 26 | grid-template-columns: auto 1fr; 27 | padding: 5px 8px; 28 | gap: 5px; 29 | 30 | &::before { 31 | content: url('images/error.svg'); 32 | --size: 15px; 33 | width: var(--size); 34 | height: var(--size); 35 | margin-top: 1px; 36 | } 37 | } 38 | 39 | .component :global(.cm-scroller) { 40 | line-height: 1.5; 41 | font-size: 0.8rem; 42 | 43 | @media (min-width: 600px) { 44 | font-size: 0.9rem; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/shared/App/Graph/index.tsx: -------------------------------------------------------------------------------- 1 | import { Signal, useComputed } from '@preact/signals'; 2 | import { h, RenderableProps, FunctionComponent } from 'preact'; 3 | import { LinearData } from 'shared-types/index'; 4 | import 'add-css:./styles.module.css'; 5 | import * as styles from './styles.module.css'; 6 | import { useElementSize, useMatchMedia } from '../utils'; 7 | 8 | interface Props { 9 | fullPoints: Signal; 10 | optimizedPoints: Signal; 11 | } 12 | 13 | function useToPath(pointsSignal: Signal) { 14 | return useComputed(() => { 15 | // We never render with null data here, but: 16 | // https://github.com/preactjs/signals/issues/315 17 | if (!pointsSignal.value) return ''; 18 | if (typeof pointsSignal.value === 'string') return pointsSignal.value; 19 | return 'M ' + pointsSignal.value.map(([x, y]) => `${x} ${1 - y}`).join(); 20 | }); 21 | } 22 | 23 | function useCanvasBounds(points: Signal) { 24 | const [containerRef, containerSize] = useElementSize(); 25 | 26 | const valueBounds = useComputed(() => { 27 | const bounds = { x1: 0, y1: 0, x2: 1, y2: 1 }; 28 | 29 | if (points.value === null) return bounds; 30 | 31 | for (const point of points.value) { 32 | if (point[0] < bounds.x1) { 33 | bounds.x1 = point[0]; 34 | } else if (point[0] > bounds.x2) { 35 | bounds.x2 = point[0]; 36 | } 37 | 38 | const val = 1 - point[1]; 39 | 40 | if (val < bounds.y1) { 41 | bounds.y1 = val; 42 | } else if (val > bounds.y2) { 43 | bounds.y2 = val; 44 | } 45 | } 46 | 47 | return bounds; 48 | }); 49 | 50 | const horizontalDemos = useMatchMedia( 51 | '(min-width: 1820px), (max-width: 1159px)', 52 | ); 53 | 54 | const canvasBounds = useComputed(() => { 55 | const padding = 30; 56 | const extraPadding = 250; 57 | const paddingRight = horizontalDemos.value ? padding : extraPadding; 58 | const paddingBottom = horizontalDemos.value ? extraPadding : padding; 59 | 60 | if (!containerSize.value) return { scale: 1, x1: 0, x2: 1, y1: 0, y2: 1 }; 61 | 62 | const canvasUsableWidth = 63 | containerSize.value.width - (padding + paddingRight); 64 | 65 | const canvasUsableHeight = 66 | containerSize.value.height - (padding + paddingBottom); 67 | 68 | const boundsWidth = valueBounds.value.x2 - valueBounds.value.x1; 69 | const boundsHeight = valueBounds.value.y2 - valueBounds.value.y1; 70 | 71 | let scale: number; 72 | 73 | if (boundsWidth / boundsHeight < canvasUsableWidth / canvasUsableHeight) { 74 | scale = canvasUsableHeight / boundsHeight; 75 | } else { 76 | scale = canvasUsableWidth / boundsWidth; 77 | } 78 | 79 | const canvasWidthToScale = containerSize.value.width / scale; 80 | const canvasHeightToScale = containerSize.value.height / scale; 81 | 82 | const x1 = 83 | valueBounds.value.x1 - 84 | (horizontalDemos.value 85 | ? (canvasWidthToScale - boundsWidth) / 2 86 | : padding / scale); 87 | 88 | const x2 = x1 + canvasWidthToScale; 89 | 90 | const y1 = 91 | valueBounds.value.y1 - 92 | (horizontalDemos.value 93 | ? padding / scale 94 | : (canvasHeightToScale - boundsHeight) / 2); 95 | 96 | const y2 = y1 + canvasHeightToScale; 97 | 98 | return { scale, x1, x2, y1, y2 }; 99 | }); 100 | 101 | return [containerRef, canvasBounds] as const; 102 | } 103 | 104 | const Graph: FunctionComponent = ({ 105 | fullPoints, 106 | optimizedPoints, 107 | }: RenderableProps) => { 108 | const fullPointsPath = useToPath(fullPoints); 109 | const optimizedPath = useToPath(optimizedPoints); 110 | const [containerRef, canvasBounds] = useCanvasBounds(optimizedPoints); 111 | 112 | const canvasScale = useComputed(() => canvasBounds.value.scale); 113 | 114 | const svgViewBox = useComputed( 115 | () => 116 | `${canvasBounds.value.x1 * canvasBounds.value.scale} ${ 117 | canvasBounds.value.y1 * canvasBounds.value.scale 118 | } ${ 119 | (canvasBounds.value.x2 - canvasBounds.value.x1) * 120 | canvasBounds.value.scale 121 | } ${ 122 | (canvasBounds.value.y2 - canvasBounds.value.y1) * 123 | canvasBounds.value.scale 124 | }`, 125 | ); 126 | 127 | const graphAxisPath = useComputed( 128 | () => `M 0 0 V ${canvasBounds.value.scale} H ${canvasBounds.value.scale}`, 129 | ); 130 | 131 | const graphSubLinesPath = useComputed(() => { 132 | let line = ''; 133 | 134 | const { scale, x1, x2, y1, y2 } = canvasBounds.value; 135 | 136 | // Negative horizontal lines 137 | for (let x = 0; x > x1; x -= 0.25) { 138 | line += `M ${x * scale} ${y1 * scale} V ${y2 * scale}`; 139 | } 140 | 141 | // Positive horizontal lines 142 | for (let x = 0; x < x2; x += 0.25) { 143 | line += `M ${x * scale} ${y1 * scale} V ${y2 * scale}`; 144 | } 145 | 146 | // Negative vertical lines 147 | for (let y = 0; y > y1; y -= 0.25) { 148 | line += `M ${x1 * scale} ${y * scale} H ${x2 * scale}`; 149 | } 150 | 151 | // Positive vertical lines 152 | for (let y = 0; y < y2; y += 0.25) { 153 | line += `M ${x1 * scale} ${y * scale} H ${x2 * scale}`; 154 | } 155 | 156 | return line; 157 | }); 158 | 159 | // A bit hacky, but if the fullPoints is a string, it's an SVG path 160 | // but we need to flip it so the origin is in the bottom left. 161 | const fullPathStyle = useComputed(() => 162 | typeof fullPoints.value === 'string' 163 | ? 'transform: scaleY(-1); transform-origin: 50% 50%;' 164 | : '', 165 | ); 166 | 167 | const hideSVG = useComputed(() => 168 | fullPoints.value === null ? 'visibility: hidden;' : '', 169 | ); 170 | 171 | return ( 172 |
173 | {!__PRERENDER__ && ( 174 | 175 | 176 | 177 | 183 | 188 | 189 | 190 | 191 | )} 192 |
193 | ); 194 | }; 195 | 196 | export { Graph as default }; 197 | -------------------------------------------------------------------------------- /src/shared/App/Graph/styles.module.css: -------------------------------------------------------------------------------- 1 | .graph-component { 2 | background: var(--surface-3); 3 | overflow: hidden; 4 | 5 | @media (prefers-color-scheme: dark) { 6 | background: var(--surface-4); 7 | } 8 | } 9 | 10 | .graph-svg { 11 | width: 100%; 12 | height: 100%; 13 | display: block; 14 | overflow: visible; 15 | pointer-events: none; 16 | } 17 | 18 | .full-path, 19 | .optimized-path { 20 | fill: none; 21 | stroke-width: 1px; 22 | vector-effect: non-scaling-stroke; 23 | } 24 | 25 | .full-path { 26 | stroke: var(--accent-2); 27 | } 28 | 29 | .optimized-path { 30 | stroke: var(--text-1); 31 | } 32 | 33 | .graph-axis { 34 | fill: none; 35 | stroke: var(--text-2); 36 | stroke-width: 2px; 37 | } 38 | 39 | .graph-sublines { 40 | fill: none; 41 | stroke: var(--surface-4); 42 | stroke-width: 1px; 43 | 44 | @media (prefers-color-scheme: dark) { 45 | stroke: var(--surface-3); 46 | stroke-width: 2px; 47 | } 48 | } 49 | 50 | .points-svg { 51 | overflow: visible; 52 | } 53 | -------------------------------------------------------------------------------- /src/shared/App/Header/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { h, FunctionalComponent } from 'preact'; 14 | import { signal } from '@preact/signals'; 15 | 16 | import 'add-css:./styles.module.css'; 17 | import * as styles from './styles.module.css'; 18 | import * as sharedStyles from '../styles.module.css'; 19 | import Select from '../Select'; 20 | import { bounce, elastic, materialEmphasized, spring } from '../demos'; 21 | import { State } from '../types'; 22 | import { hideFromPrerender } from '../utils'; 23 | 24 | const demos = { 25 | Spring: spring, 26 | Bounce: bounce, 27 | 'Simple elastic': elastic, 28 | 'Material Design emphasized easing': materialEmphasized, 29 | } as const; 30 | 31 | interface Props { 32 | onPresetSelect: (newState: Partial) => void; 33 | } 34 | 35 | const presets = signal('Presets'); 36 | 37 | const Header: FunctionalComponent = ({ onPresetSelect }) => { 38 | const selectValue = signal('?'); 39 | 40 | return ( 41 |
42 |

43 | linear() generator 44 |

45 |
46 | 58 |
59 | 64 | 65 | GitHub repository 66 | 67 | 68 | 69 | 70 | 71 |
72 | ); 73 | }; 74 | 75 | export default Header; 76 | -------------------------------------------------------------------------------- /src/shared/App/Header/styles.module.css: -------------------------------------------------------------------------------- 1 | .site-header { 2 | background: var(--surface-1); 3 | padding: 0 calc(var(--ui-side-margin) + var(--content-side-padding)); 4 | display: grid; 5 | grid-template-columns: 1fr; 6 | grid-auto-flow: column; 7 | grid-auto-columns: auto; 8 | gap: 1rem; 9 | align-items: center; 10 | } 11 | 12 | .site-title { 13 | color: var(--accent); 14 | font-size: 1.7rem; 15 | margin: 0; 16 | padding: 0.9rem 0; 17 | } 18 | 19 | .extended-title { 20 | display: none; 21 | 22 | @media (min-width: 480px) { 23 | display: inline; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/App/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import { Signal, useComputed, useSignalEffect } from '@preact/signals'; 2 | import { h, RenderableProps, FunctionComponent, Fragment } from 'preact'; 3 | import { useRef } from 'preact/hooks'; 4 | import { CodeHighlight, CodeType } from '../types'; 5 | import Editor from '../Editor'; 6 | import * as sharedStyles from '../styles.module.css'; 7 | import { defaultJS, defaultSVG } from '../demos'; 8 | import Select from '../Select'; 9 | import { hideFromPrerender } from '../utils'; 10 | 11 | function useCachedFormatCodes( 12 | code: Signal, 13 | codeType: Signal, 14 | ) { 15 | const lastJSCode = useRef(defaultJS.code); 16 | const lastSVGCode = useRef(defaultSVG.code); 17 | 18 | useSignalEffect(() => { 19 | if (codeType.value === CodeType.JS) { 20 | lastJSCode.current = code.value; 21 | } else { 22 | lastSVGCode.current = code.value; 23 | } 24 | }); 25 | 26 | return { [CodeType.JS]: lastJSCode, [CodeType.SVG]: lastSVGCode }; 27 | } 28 | 29 | interface Props { 30 | code: Signal; 31 | codeType: Signal; 32 | error: Signal; 33 | onChange: (code: string, codeType: CodeType) => void; 34 | } 35 | 36 | const Input: FunctionComponent = ({ 37 | code, 38 | codeType, 39 | error, 40 | onChange, 41 | }: RenderableProps) => { 42 | const editorHighlight = useComputed(() => 43 | codeType.value == CodeType.JS ? CodeHighlight.JS : CodeHighlight.SVG, 44 | ); 45 | const codeTypeString = useComputed(() => String(codeType.value)); 46 | const lastCodes = useCachedFormatCodes(code, codeType); 47 | 48 | function onSelectChange(newVal: string) { 49 | const newValNum = Number(newVal) as CodeType; 50 | onChange(lastCodes[newValNum].current, newValNum); 51 | } 52 | 53 | return ( 54 | <> 55 |
56 |
57 |

Input

58 |

Provide easing as JavaScript or SVG

59 |
60 | 67 |
68 | onChange(val, codeType.value)} 72 | language={editorHighlight} 73 | /> 74 | 75 | ); 76 | }; 77 | 78 | export { Input as default }; 79 | -------------------------------------------------------------------------------- /src/shared/App/Optim/index.tsx: -------------------------------------------------------------------------------- 1 | import { Signal, useComputed } from '@preact/signals'; 2 | import { h, Fragment, RenderableProps, FunctionComponent } from 'preact'; 3 | import { useRef, useLayoutEffect, useCallback, useEffect } from 'preact/hooks'; 4 | import 'add-css:./styles.module.css'; 5 | import * as styles from './styles.module.css'; 6 | import * as sharedStyles from '../styles.module.css'; 7 | import Range from '../Range'; 8 | import { hideFromPrerender } from '../utils'; 9 | 10 | interface Props { 11 | simplify: Signal; 12 | round: Signal; 13 | onInput: (simplify: number, round: number) => void; 14 | } 15 | 16 | const Optim: FunctionComponent = ({ 17 | round, 18 | simplify, 19 | onInput, 20 | }: RenderableProps) => { 21 | return ( 22 |
23 | 33 | 43 |
44 | ); 45 | }; 46 | 47 | export { Optim as default }; 48 | -------------------------------------------------------------------------------- /src/shared/App/Optim/styles.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | display: grid; 3 | grid-template-columns: 1fr 166px; 4 | background: var(--surface-1); 5 | padding: 0 var(--content-side-padding); 6 | min-height: 100px; 7 | align-items: center; 8 | gap: 1rem; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/App/Range/index.tsx: -------------------------------------------------------------------------------- 1 | import { Signal, useComputed, useSignal } from '@preact/signals'; 2 | import { h, RenderableProps, FunctionComponent } from 'preact'; 3 | import { useLayoutEffect, useRef } from 'preact/hooks'; 4 | import 'add-css:./styles.module.css'; 5 | import * as styles from './styles.module.css'; 6 | 7 | interface Props { 8 | value: Signal; 9 | min: number; 10 | max: number; 11 | step: number | 'any'; 12 | onInput: (value: number) => void; 13 | } 14 | 15 | const Range: FunctionComponent = ({ 16 | value, 17 | max, 18 | min, 19 | step, 20 | onInput, 21 | }: RenderableProps) => { 22 | const fakeRangeStyle = useComputed( 23 | () => `--pos: ${(value.value - min) / (max - min)}`, 24 | ); 25 | 26 | return ( 27 |
28 | 35 | 43 | onInput((event.currentTarget as HTMLInputElement).valueAsNumber) 44 | } 45 | /> 46 |
47 | ); 48 | }; 49 | 50 | export { Range as default }; 51 | -------------------------------------------------------------------------------- /src/shared/App/Range/styles.module.css: -------------------------------------------------------------------------------- 1 | .range-component { 2 | display: grid; 3 | 4 | > * { 5 | grid-area: 1/1; 6 | } 7 | } 8 | 9 | .range-input { 10 | opacity: 0; 11 | } 12 | 13 | .fake-range { 14 | position: relative; 15 | contain: layout; 16 | --thumb-size: 20px; 17 | height: var(--thumb-size); 18 | --pos: 0.5; 19 | pointer-events: none; 20 | } 21 | 22 | .active-line, 23 | .inactive-line { 24 | height: 3px; 25 | background: var(--blue-1); 26 | border-radius: 100px; 27 | position: absolute; 28 | inset: auto 0; 29 | top: 50%; 30 | transform: translateY(-50%); 31 | width: calc( 32 | (100% - var(--thumb-size)) * var(--pos) + (var(--thumb-size) / 2) 33 | ); 34 | } 35 | 36 | .inactive-line { 37 | background: var(--surface-0); 38 | margin-left: auto; 39 | width: calc( 40 | (100% - var(--thumb-size)) * (1 - var(--pos)) + (var(--thumb-size) / 2) 41 | ); 42 | } 43 | 44 | .track { 45 | position: absolute; 46 | inset: 0; 47 | right: var(--thumb-size); 48 | } 49 | 50 | .control { 51 | position: absolute; 52 | left: calc(var(--pos) * 100%); 53 | top: 0; 54 | 55 | height: var(--thumb-size); 56 | aspect-ratio: 1/1; 57 | border-radius: 100%; 58 | box-sizing: border-box; 59 | background: var(--surface-1); 60 | border: 1px solid var(--border-color); 61 | 62 | @media (prefers-color-scheme: dark) { 63 | background: var(--surface-2); 64 | border-color: var(--gray-7); 65 | } 66 | } -------------------------------------------------------------------------------- /src/shared/App/Select/index.tsx: -------------------------------------------------------------------------------- 1 | import { Signal, useComputed, useSignal } from '@preact/signals'; 2 | import { h, RenderableProps, FunctionComponent, JSX } from 'preact'; 3 | import { useLayoutEffect, useRef } from 'preact/hooks'; 4 | import 'add-css:./styles.module.css'; 5 | import * as styles from './styles.module.css'; 6 | import { ComponentChild } from 'preact'; 7 | import { VNode } from 'preact'; 8 | 9 | interface Props { 10 | value: Signal; 11 | displayedValue?: Signal; 12 | onChange: (value: string) => void; 13 | } 14 | 15 | const Select: FunctionComponent = ({ 16 | value, 17 | onChange, 18 | children, 19 | displayedValue, 20 | }: RenderableProps) => { 21 | const selectedOptionText = useComputed(() => { 22 | if (displayedValue) return displayedValue.value; 23 | 24 | const childrenArray = ( 25 | Array.isArray(children) ? children.flat() : [children] 26 | ) as ComponentChild[]; 27 | 28 | const options = childrenArray.filter( 29 | (child) => 30 | child && 31 | typeof child === 'object' && 32 | 'type' in child && 33 | child.type === 'option', 34 | ) as JSX.Element[]; 35 | 36 | const selectedOption = options.find( 37 | (option) => String(option.props.value) === value.value, 38 | ); 39 | 40 | const selectedOptionText = selectedOption 41 | ? selectedOption.props.children 42 | : ''; 43 | 44 | return selectedOptionText as string; 45 | }); 46 | 47 | const selectEl = useRef(null); 48 | 49 | function onSelectChange() { 50 | const el = selectEl.current!; 51 | const selectedOption = el.options[el.selectedIndex]; 52 | onChange(selectedOption.value); 53 | } 54 | 55 | return ( 56 |
57 | 63 | 71 |
72 | ); 73 | }; 74 | 75 | export { Select as default }; 76 | -------------------------------------------------------------------------------- /src/shared/App/Select/styles.module.css: -------------------------------------------------------------------------------- 1 | .select { 2 | overflow: clip; 3 | position: relative; 4 | } 5 | 6 | .select-current { 7 | pointer-events: none; 8 | border: 1.5px solid var(--border-color); 9 | border-radius: 4px; 10 | display: grid; 11 | grid-template-columns: 1fr 11px; 12 | padding: 0.2rem 0.7rem 0.2rem 0.8rem; 13 | gap: 0.8rem; 14 | align-items: center; 15 | 16 | svg { 17 | width: 100%; 18 | stroke: var(--text-2); 19 | } 20 | } 21 | 22 | .real-select { 23 | color-scheme: dark light; 24 | font-size: inherit; 25 | opacity: 0; 26 | position: absolute; 27 | min-width: 100%; 28 | min-height: 100%; 29 | top: 0; 30 | right: 0; 31 | } 32 | -------------------------------------------------------------------------------- /src/shared/App/columnsSignal.ts: -------------------------------------------------------------------------------- 1 | import { signal } from '@preact/signals'; 2 | 3 | type Cols = 1 | 2 | 3; 4 | const twoColMedia = matchMedia('(min-width: 1160px)'); 5 | const threeColMedia = matchMedia('(min-width: 1160px)'); 6 | 7 | function getCols(): Cols { 8 | if (threeColMedia.matches) return 3; 9 | if (twoColMedia.matches) return 2; 10 | return 1; 11 | } 12 | 13 | const cols = signal(getCols()); 14 | 15 | function onMediaChange() { 16 | cols.value = getCols(); 17 | } 18 | 19 | twoColMedia.addEventListener('change', onMediaChange); 20 | threeColMedia.addEventListener('change', onMediaChange); 21 | 22 | export { cols as default }; 23 | -------------------------------------------------------------------------------- /src/shared/App/demos.ts: -------------------------------------------------------------------------------- 1 | import { CodeType } from './types'; 2 | 3 | const bounceCode = `self.bounce = function(pos) { 4 | const n1 = 7.5625; 5 | const d1 = 2.75; 6 | 7 | if (pos < 1 / d1) { 8 | return n1 * pos * pos; 9 | } else if (pos < 2 / d1) { 10 | return n1 * (pos -= 1.5 / d1) * pos + 0.75; 11 | } else if (pos < 2.5 / d1) { 12 | return n1 * (pos -= 2.25 / d1) * pos + 0.9375; 13 | } else { 14 | return n1 * (pos -= 2.625 / d1) * pos + 0.984375; 15 | } 16 | }`; 17 | 18 | const materialEmphasizedCode = `M 0,0 19 | C 0.05, 0, 0.133333, 0.06, 0.166666, 0.4 20 | C 0.208333, 0.82, 0.25, 1, 1, 1`; 21 | 22 | const elasticCode = `self.elastic = function(x) { 23 | if (x === 0 || x === 1) return x; 24 | const c4 = (2 * Math.PI) / 3; 25 | return Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * c4) + 1; 26 | }`; 27 | 28 | const springCode = `const [duration, func] = createSpring({ 29 | mass: 1, 30 | stiffness: 100, 31 | damping: 10, 32 | velocity: 0, 33 | }); 34 | 35 | /* 36 | Export your easing function as a global. 37 | The name you use here will appear in the output. 38 | The easing function must take a number as input, 39 | where 0 is the start, and 1 is the end. 40 | It must return the 'eased' value. 41 | */ 42 | self.spring = func; 43 | /* 44 | Some easings have an ideal duration, like this one. 45 | You can export it to the global, in milliseconds, 46 | and it will be used in the output. 47 | This is optional. 48 | */ 49 | self.duration = duration; 50 | 51 | function createSpring({ mass, stiffness, damping, velocity }) { 52 | const w0 = Math.sqrt(stiffness / mass); 53 | const zeta = damping / (2 * Math.sqrt(stiffness * mass)); 54 | const wd = zeta < 1 ? w0 * Math.sqrt(1 - zeta * zeta) : 0; 55 | const b = zeta < 1 ? (zeta * w0 + -velocity) / wd : -velocity + w0; 56 | 57 | function solver(t) { 58 | if (zeta < 1) { 59 | t = 60 | Math.exp(-t * zeta * w0) * 61 | (1 * Math.cos(wd * t) + b * Math.sin(wd * t)); 62 | } else { 63 | t = (1 + b * t) * Math.exp(-t * w0); 64 | } 65 | 66 | return 1 - t; 67 | } 68 | 69 | const duration = (() => { 70 | const step = 1 / 6; 71 | let time = 0; 72 | 73 | while (true) { 74 | if (Math.abs(1 - solver(time)) < 0.001) { 75 | const restStart = time; 76 | let restSteps = 1; 77 | while (true) { 78 | time += step; 79 | if (Math.abs(1 - solver(time)) >= 0.001) break; 80 | restSteps++; 81 | if (restSteps === 16) return restStart; 82 | } 83 | } 84 | time += step; 85 | } 86 | })(); 87 | 88 | return [duration * 1000, (t) => solver(duration * t)]; 89 | }`; 90 | 91 | export const bounce = { 92 | codeType: CodeType.JS, 93 | code: bounceCode, 94 | }; 95 | 96 | export const materialEmphasized = { 97 | codeType: CodeType.SVG, 98 | code: materialEmphasizedCode, 99 | }; 100 | 101 | export const elastic = { 102 | codeType: CodeType.JS, 103 | code: elasticCode, 104 | }; 105 | 106 | export const spring = { 107 | codeType: CodeType.JS, 108 | code: springCode, 109 | }; 110 | 111 | export const defaultJS = spring; 112 | export const defaultSVG = materialEmphasized; 113 | -------------------------------------------------------------------------------- /src/shared/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, Fragment, RenderableProps, FunctionComponent } from 'preact'; 2 | import { 3 | useComputed, 4 | useSignal, 5 | signal, 6 | useSignalEffect, 7 | } from '@preact/signals'; 8 | import Editor from './Editor'; 9 | import 'add-css:./styles.module.css'; 10 | import * as styles from './styles.module.css'; 11 | import Graph from './Graph'; 12 | import useFullPointGeneration from './useFullPointGeneration'; 13 | import { CodeHighlight, CodeType } from './types'; 14 | import Optim from './Optim'; 15 | import useOptimizedPoints from './useOptimizedPoints'; 16 | import useLinearSyntax from './useLinearSyntax'; 17 | import AnimatedDemos from './AnimatedDemos'; 18 | import useFriendlyLinearCode from './useFriendlyLinearCode'; 19 | import useURLState from './useURLState'; 20 | import CopyButton from './CopyButton'; 21 | import Input from './Input'; 22 | import Header from './Header'; 23 | import { hideFromPrerender } from './utils'; 24 | 25 | interface Props {} 26 | 27 | const initiallyPlay = __PRERENDER__ 28 | ? true 29 | : matchMedia('(prefers-reduced-motion: no-preference)').matches; 30 | 31 | const App: FunctionComponent = ({}: RenderableProps) => { 32 | const { codeType, code, simplify, round, update } = useURLState(); 33 | 34 | const playAnims = useSignal(initiallyPlay); 35 | const durationInputValue = useSignal('1.333'); 36 | const duration = useComputed( 37 | () => (Number(durationInputValue.value) || 1.333) * 1000, 38 | ); 39 | 40 | const [fullPoints, codeError, name, idealDuration] = useFullPointGeneration( 41 | code, 42 | codeType, 43 | ); 44 | 45 | // Update the duration input if we get a valid ideal duration 46 | useSignalEffect(() => { 47 | if (idealDuration.value) { 48 | durationInputValue.value = (idealDuration.value / 1000).toFixed(3); 49 | } 50 | }); 51 | 52 | const optimizedPoints = useOptimizedPoints(fullPoints, simplify, round); 53 | 54 | // Just pass through the original SVG for the graph, if the input is SVG 55 | const graphFullPoints = useComputed(() => 56 | codeType.value === CodeType.JS ? fullPoints.value : code.value, 57 | ); 58 | const linear = useLinearSyntax(optimizedPoints, round); 59 | const friendlyExample = useFriendlyLinearCode(linear, name, idealDuration, { 60 | addLineBreaksWithinLinear: true, 61 | }); 62 | 63 | // https://issues.chromium.org/issues/361652145 64 | // A Chrome regression causes linear() to fail if there's whitespace before the first value 65 | const friendlyExampleWithoutLinearBreaks = useFriendlyLinearCode( 66 | linear, 67 | name, 68 | idealDuration, 69 | ); 70 | 71 | // Create slightly optimized version for the demos 72 | const slightlyOptimizedPoints = useOptimizedPoints( 73 | fullPoints, 74 | useSignal(0.00001), 75 | useSignal(5), 76 | ); 77 | const slightlyOptimizedLinear = useLinearSyntax( 78 | slightlyOptimizedPoints, 79 | useSignal(5), 80 | ); 81 | 82 | const playToggleText = useComputed(() => 83 | playAnims.value ? 'Pause' : 'Play', 84 | ); 85 | 86 | const playToggleIconPath = useComputed(() => 87 | playAnims.value 88 | ? // Pause icon 89 | 'M525 856V296h235v560H525Zm-325 0V296h235v560H200Zm385-60h115V356H585v440Zm-325 0h115V356H260v440Zm0-440v440-440Zm325 0v440-440Z' 90 | : // Play icon 91 | 'M320 853V293l440 280-440 280Zm60-280Zm0 171 269-171-269-171v342Z', 92 | ); 93 | 94 | return ( 95 | <> 96 |
update(newState)} /> 97 |
98 |
99 | update({ code, codeType })} 104 | /> 105 |
106 |
110 |
111 |
112 |
113 |

Preview

114 |

Here's how it looks:

115 |
116 | 129 | 141 |
142 |
143 | 147 |
148 | 154 |
155 |
156 |
157 |
158 |
159 |
160 |

Output

161 |

Some shiny new CSS!

162 |
163 | 164 |
165 | 170 |
171 |
175 | 177 | update({ 178 | simplify: newSimplify, 179 | round: newRound, 180 | }) 181 | } 182 | round={round} 183 | simplify={simplify} 184 | /> 185 |
186 |
187 |
188 | 189 | ); 190 | }; 191 | 192 | export { App as default }; 193 | -------------------------------------------------------------------------------- /src/shared/App/styles.module.css: -------------------------------------------------------------------------------- 1 | .app-grid { 2 | margin-top: 20px; 3 | 4 | @media (min-width: 1160px) { 5 | margin: var(--ui-side-margin); 6 | display: grid; 7 | overflow: hidden; 8 | gap: var(--ui-side-margin); 9 | 10 | grid-template: 11 | 'input preview-output' 1fr 12 | / 1fr 1fr; 13 | } 14 | @media (min-width: 1820px) { 15 | grid-template: 16 | 'input preview-output preview-output' 1fr 17 | / 1fr 1fr 1fr; 18 | } 19 | } 20 | 21 | .process-scripts-iframe { 22 | width: 0; 23 | height: 0; 24 | opacity: 0; 25 | position: absolute; 26 | left: 0; 27 | top: -1000px; 28 | } 29 | 30 | .section-header { 31 | background: var(--surface-1); 32 | min-height: 100px; 33 | padding: 0 var(--content-side-padding); 34 | display: grid; 35 | align-items: center; 36 | grid-template-columns: 1fr; 37 | grid-auto-columns: auto; 38 | grid-auto-flow: column; 39 | gap: 1rem; 40 | } 41 | 42 | .section-header-title { 43 | display: grid; 44 | grid-auto-flow: row; 45 | gap: 0.5rem; 46 | 47 | h2 { 48 | margin: 0; 49 | font-weight: normal; 50 | font-size: 1.5rem; 51 | line-height: 1; 52 | } 53 | 54 | p { 55 | margin: 0; 56 | font-size: 0.9rem; 57 | line-height: 1; 58 | } 59 | } 60 | 61 | .select-label { 62 | display: block; 63 | } 64 | 65 | .label-text, 66 | .label-text-end { 67 | display: block; 68 | text-transform: uppercase; 69 | font-size: 0.9rem; 70 | margin-bottom: 0.2rem; 71 | color: var(--text-2); 72 | } 73 | 74 | .label-text-end { 75 | text-align: right; 76 | } 77 | 78 | .app-module { 79 | display: grid; 80 | overflow: hidden; 81 | grid-template-rows: auto 1fr; 82 | } 83 | 84 | .preview-output { 85 | @media (min-width: 1160px) { 86 | display: grid; 87 | overflow: hidden; 88 | gap: var(--ui-side-margin); 89 | 90 | grid-template: 91 | 'preview' minmax(352px, 1fr) 92 | 'output' minmax(113px, 311px) 93 | 'simplify' auto / 1fr; 94 | } 95 | @media (min-width: 1820px) { 96 | grid-template: 97 | 'preview output' 1fr 98 | 'simplify simplify' auto 99 | / 1fr 1fr; 100 | } 101 | } 102 | 103 | .preview-content { 104 | display: grid; 105 | overflow: hidden; 106 | position: relative; 107 | height: 80svh; 108 | max-height: 650px; 109 | 110 | @media (min-width: 1160px) { 111 | height: auto; 112 | max-height: none; 113 | } 114 | } 115 | 116 | .animated-demos { 117 | --size: 190px; 118 | position: absolute; 119 | inset: var(--content-side-padding); 120 | left: auto; 121 | width: var(--size); 122 | display: grid; 123 | 124 | @media (min-width: 1820px), (max-width: 1159px) { 125 | width: auto; 126 | height: var(--size); 127 | inset: var(--content-side-padding); 128 | top: auto; 129 | } 130 | } 131 | 132 | .input { 133 | background: none; 134 | border: 1.5px solid var(--border-color); 135 | border-radius: 4px; 136 | padding: 0.2rem 0.8rem 0.2rem; 137 | font: inherit; 138 | color: inherit; 139 | } 140 | 141 | .duration-input { 142 | width: 60px; 143 | -moz-appearance: textfield; 144 | text-align: right; 145 | 146 | &::-webkit-outer-spin-button, 147 | &::-webkit-inner-spin-button { 148 | -webkit-appearance: none; 149 | margin: 0; 150 | } 151 | } 152 | 153 | .section-header-icon-button { 154 | all: unset; 155 | cursor: pointer; 156 | display: inline-block; 157 | border: 1.5px solid var(--border-color); 158 | --size: 39px; 159 | width: var(--size); 160 | height: var(--size); 161 | border-radius: var(--size); 162 | overflow: hidden; 163 | display: grid; 164 | align-content: center; 165 | justify-content: center; 166 | overflow: hidden; 167 | 168 | &:focus-visible { 169 | border-color: var(--text-1); 170 | } 171 | 172 | & > svg { 173 | --size: 24px; 174 | width: var(--size); 175 | height: var(--size); 176 | fill: var(--icon-color); 177 | } 178 | } 179 | 180 | .section-header-icon-button-text { 181 | position: absolute; 182 | width: 0; 183 | height: 0; 184 | opacity: 0; 185 | overflow: hidden; 186 | } 187 | 188 | .simplify-module { 189 | @media (max-width: 1159px) { 190 | position: sticky; 191 | bottom: 0; 192 | overflow: visible; 193 | 194 | &::before { 195 | content: ''; 196 | position: absolute; 197 | left: 0; 198 | right: 0; 199 | bottom: 100%; 200 | height: 12px; 201 | pointer-events: none; 202 | opacity: 0.1; 203 | background: linear-gradient( 204 | to bottom, 205 | hsla(0, 0%, 0%, 0) 0%, 206 | hsla(0, 0%, 0%, 0.013) 8.1%, 207 | hsla(0, 0%, 0%, 0.049) 15.5%, 208 | hsla(0, 0%, 0%, 0.104) 22.5%, 209 | hsla(0, 0%, 0%, 0.175) 29%, 210 | hsla(0, 0%, 0%, 0.259) 35.3%, 211 | hsla(0, 0%, 0%, 0.352) 41.2%, 212 | hsla(0, 0%, 0%, 0.45) 47.1%, 213 | hsla(0, 0%, 0%, 0.55) 52.9%, 214 | hsla(0, 0%, 0%, 0.648) 58.8%, 215 | hsla(0, 0%, 0%, 0.741) 64.7%, 216 | hsla(0, 0%, 0%, 0.825) 71%, 217 | hsla(0, 0%, 0%, 0.896) 77.5%, 218 | hsla(0, 0%, 0%, 0.951) 84.5%, 219 | hsla(0, 0%, 0%, 0.987) 91.9%, 220 | hsl(0, 0%, 0%) 100% 221 | ); 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/shared/App/types.ts: -------------------------------------------------------------------------------- 1 | export const enum CodeType { 2 | JS, 3 | SVG, 4 | } 5 | 6 | export const enum CodeHighlight { 7 | JS, 8 | SVG, 9 | CSS, 10 | } 11 | 12 | export interface State { 13 | codeType: CodeType; 14 | code: string; 15 | simplify: number; 16 | round: number; 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/App/useFriendlyLinearCode.ts: -------------------------------------------------------------------------------- 1 | import { Signal, useComputed } from '@preact/signals'; 2 | 3 | const durationFormat = new Intl.NumberFormat('en-US', { 4 | maximumFractionDigits: 3, 5 | }); 6 | 7 | export default function useFriendlyLinearCode( 8 | parts: Signal, 9 | name: Signal, 10 | idealDuration: Signal, 11 | { addLineBreaksWithinLinear = false } = {}, 12 | ): Signal { 13 | return useComputed(() => { 14 | if (parts.value.length === 0) return ''; 15 | 16 | const lineLength = addLineBreaksWithinLinear ? 80 : Infinity; 17 | 18 | let outputStart = ':root {\n'; 19 | let linearStart = ` --${name}-easing: linear(`; 20 | let linearEnd = ');'; 21 | let outputEnd = '\n}'; 22 | let lines = []; 23 | let line = ''; 24 | 25 | const lineIndentSize = 4; 26 | 27 | for (const part of parts.value) { 28 | // 1 for comma 29 | if (line.length + part.length + lineIndentSize + 1 > lineLength) { 30 | lines.push(line + ','); 31 | line = ''; 32 | } 33 | if (line) line += ', '; 34 | line += part; 35 | } 36 | 37 | if (line) lines.push(line); 38 | 39 | let linearLines = ''; 40 | 41 | if ( 42 | lines.length === 1 && 43 | linearStart.length + lines[0].length + linearEnd.length < lineLength 44 | ) { 45 | linearLines = linearStart + lines[0] + linearEnd; 46 | } else { 47 | linearLines = 48 | linearStart + '\n ' + lines.join('\n ') + '\n ' + linearEnd; 49 | } 50 | 51 | let idealDurationLine = ''; 52 | 53 | if (idealDuration.value !== 0) { 54 | idealDurationLine = `\n --${name}-duration: ${durationFormat.format( 55 | idealDuration.value / 1000, 56 | )}s;`; 57 | } 58 | 59 | return outputStart + linearLines + idealDurationLine + outputEnd; 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /src/shared/App/useFullPointGeneration/index.ts: -------------------------------------------------------------------------------- 1 | import { Signal, useSignal, useSignalEffect } from '@preact/signals'; 2 | import { useEffect, useRef } from 'preact/hooks'; 3 | import { LinearData } from 'shared-types/index'; 4 | import { CodeType } from '../types'; 5 | import { 6 | default as processEasing, 7 | ProcessScriptEasingError, 8 | } from './processEasing'; 9 | 10 | const processingDebounce = 300; 11 | 12 | export default function useFullPointGeneration( 13 | code: Signal, 14 | type: Signal, 15 | ): [ 16 | linearData: Signal, 17 | codeError: Signal, 18 | name: Signal, 19 | duration: Signal, 20 | ] { 21 | const fullPoints = useSignal(null); 22 | const codeError = useSignal(''); 23 | const name = useSignal(''); 24 | const duration = useSignal(0); 25 | const currentProcessingControllerRef = useRef(null); 26 | const processingTimeoutRef = useRef(0); 27 | const firstProcessRef = useRef(true); 28 | const lastCodeTypeRef = useRef(null); 29 | 30 | useSignalEffect(() => { 31 | const lastCodeType = lastCodeTypeRef.current; 32 | lastCodeTypeRef.current = type.value; 33 | const currentCode = code.value; 34 | currentProcessingControllerRef.current?.abort(); 35 | clearTimeout(processingTimeoutRef.current); 36 | 37 | async function process() { 38 | firstProcessRef.current = false; 39 | currentProcessingControllerRef.current = new AbortController(); 40 | try { 41 | const result = await processEasing( 42 | currentProcessingControllerRef.current.signal, 43 | currentCode, 44 | type.value, 45 | ); 46 | fullPoints.value = result.points; 47 | codeError.value = ''; 48 | name.value = result.name; 49 | duration.value = result.duration || 0; 50 | } catch (error) { 51 | if ((error as Error).name === 'AbortError') return; 52 | fullPoints.value = null; 53 | name.value = ''; 54 | 55 | if (error instanceof ProcessScriptEasingError) { 56 | let errorString = error.message; 57 | if ( 58 | error.fileName && 59 | error.fileName.startsWith('data:') && 60 | error.lineNumber && 61 | error.columnNumber 62 | ) { 63 | errorString += ` at line ${error.lineNumber}, column ${error.columnNumber}`; 64 | } 65 | 66 | codeError.value = errorString; 67 | } else { 68 | codeError.value = (error as Error).message; 69 | } 70 | } 71 | } 72 | 73 | // Don't debounce the first call, or if code type changes 74 | if (firstProcessRef.current || type.value !== lastCodeType) { 75 | fullPoints.value = null; 76 | codeError.value = ''; 77 | process(); 78 | } else { 79 | processingTimeoutRef.current = ( 80 | setTimeout as (typeof window)['setTimeout'] 81 | )(process, processingDebounce); 82 | } 83 | }); 84 | 85 | return [fullPoints, codeError, name, duration]; 86 | } 87 | -------------------------------------------------------------------------------- /src/shared/App/useFullPointGeneration/processEasing.ts: -------------------------------------------------------------------------------- 1 | import * as styles from '../styles.module.css'; 2 | import { doAbortable } from '../utils'; 3 | import type { 4 | LinearData, 5 | PostMessageError, 6 | ProcessResult, 7 | } from 'shared-types/index'; 8 | import { CodeType } from '../types'; 9 | 10 | const [iframe, loaded] = (() => { 11 | if (__PRERENDER__) return [null, null]; 12 | const iframe = document.createElement('iframe'); 13 | iframe.setAttribute('sandbox', 'allow-scripts'); 14 | iframe.src = '/process-script/'; 15 | iframe.className = styles.processScriptsIframe; 16 | const loaded = new Promise( 17 | (resolve) => (iframe.onload = () => resolve()), 18 | ); 19 | document.body.appendChild(iframe); 20 | return [iframe, loaded]; 21 | })(); 22 | 23 | let queue: Promise = Promise.resolve(); 24 | 25 | export class ProcessScriptEasingError extends Error { 26 | fileName?: string; 27 | lineNumber?: number; 28 | columnNumber?: number; 29 | functionName?: string; 30 | 31 | constructor(postMessageError: PostMessageError) { 32 | super(postMessageError.message); 33 | this.name = 'ProcessScriptEasingError'; 34 | 35 | if ('fileName' in postMessageError) { 36 | this.fileName = postMessageError.fileName; 37 | this.lineNumber = postMessageError.lineNumber; 38 | this.columnNumber = postMessageError.columnNumber; 39 | this.functionName = postMessageError.functionName; 40 | } 41 | } 42 | } 43 | 44 | export default function processEasing( 45 | signal: AbortSignal, 46 | script: string, 47 | type: CodeType, 48 | ): Promise { 49 | return (queue = queue 50 | .catch(() => {}) 51 | .then(() => 52 | doAbortable(signal, async (setAbortAction) => { 53 | // Check for validity of SVG on the main thread. 54 | // The parser in the worker is too permissive. 55 | if (type === CodeType.SVG) { 56 | if ( 57 | !CSS.supports(`clip-path: path("${script.replaceAll('\n', ' ')}")`) 58 | ) { 59 | throw new TypeError('Invalid SVG path'); 60 | } 61 | } 62 | 63 | const { port1, port2 } = new MessageChannel(); 64 | 65 | await loaded; 66 | 67 | const done = () => 68 | iframe!.contentWindow!.postMessage( 69 | { action: 'terminate-worker' }, 70 | '*', 71 | ); 72 | 73 | setAbortAction(done); 74 | 75 | const resultPromise = new Promise((resolve, reject) => { 76 | port1.onmessage = ({ data }) => { 77 | if (data.error) reject(new ProcessScriptEasingError(data.error)); 78 | else { 79 | resolve(data.result); 80 | } 81 | 82 | done(); 83 | }; 84 | }); 85 | 86 | iframe!.contentWindow!.postMessage( 87 | { 88 | action: type === CodeType.JS ? 'process-script' : 'process-svg', 89 | script, 90 | port: port2, 91 | }, 92 | '*', 93 | [port2], 94 | ); 95 | 96 | return resultPromise; 97 | }), 98 | )) as Promise; 99 | } 100 | -------------------------------------------------------------------------------- /src/shared/App/useLinearSyntax.ts: -------------------------------------------------------------------------------- 1 | import { Signal, useComputed, useSignalEffect } from '@preact/signals'; 2 | import { LinearData } from 'shared-types/index'; 3 | 4 | export default function useLinearSyntax( 5 | points: Signal, 6 | round: Signal, 7 | ): Signal { 8 | return useComputed(() => { 9 | if (!points.value) return []; 10 | const xFormat = new Intl.NumberFormat('en-US', { 11 | maximumFractionDigits: Math.max(round.value - 2, 0), 12 | }); 13 | const yFormat = new Intl.NumberFormat('en-US', { 14 | maximumFractionDigits: round.value, 15 | }); 16 | 17 | const pointsValue = points.value; 18 | const valuesWithRedundantX = new Set<[number, number]>(); 19 | const maxDelta = 1 / 10 ** round.value; 20 | 21 | // Figure out entries that don't need an explicit position value 22 | for (const [i, value] of pointsValue.entries()) { 23 | const [x] = value; 24 | // If the first item's position is 0, then we don't need to state the position 25 | if (i === 0) { 26 | if (x === 0) valuesWithRedundantX.add(value); 27 | continue; 28 | } 29 | // If the last entry's position is 1, and the item before it is less than 1, then we don't need to state the position 30 | if (i === pointsValue.length - 1) { 31 | const previous = pointsValue[i - 1][0]; 32 | if (x === 1 && previous <= 1) valuesWithRedundantX.add(value); 33 | continue; 34 | } 35 | 36 | // If the position is the average of the previous and next positions, then we don't need to state the position 37 | const previous = pointsValue[i - 1][0]; 38 | const next = pointsValue[i + 1][0]; 39 | 40 | const averagePos = (next - previous) / 2 + previous; 41 | const delta = Math.abs(x - averagePos); 42 | 43 | if (delta < maxDelta) valuesWithRedundantX.add(value); 44 | } 45 | 46 | // Group into sections with same y 47 | const groupedValues: LinearData[] = [[pointsValue[0]]]; 48 | 49 | for (const value of pointsValue.slice(1)) { 50 | if (value[1] === groupedValues.at(-1)![0][1]) { 51 | groupedValues.at(-1)!.push(value); 52 | } else { 53 | groupedValues.push([value]); 54 | } 55 | } 56 | 57 | const outputValues = groupedValues.map((group) => { 58 | const yValue = yFormat.format(group[0][1]); 59 | 60 | const regularValue = group 61 | .map((value) => { 62 | const [x] = value; 63 | let output = yValue; 64 | 65 | if (!valuesWithRedundantX.has(value)) { 66 | output += ' ' + xFormat.format(x * 100) + '%'; 67 | } 68 | 69 | return output; 70 | }) 71 | .join(', '); 72 | 73 | if (group.length === 1) return regularValue; 74 | 75 | // Maybe it's shorter to provide a value that skips steps? 76 | const xVals = [group[0][0], group.at(-1)![0]]; 77 | const positionalValues = xVals 78 | .map((x) => xFormat.format(x * 100) + '%') 79 | .join(' '); 80 | 81 | const skipValue = `${yValue} ${positionalValues}`; 82 | 83 | return skipValue.length > regularValue.length ? regularValue : skipValue; 84 | }); 85 | 86 | return outputValues; 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /src/shared/App/useOptimizedPoints.ts: -------------------------------------------------------------------------------- 1 | import { Signal, useComputed, useSignalEffect } from '@preact/signals'; 2 | import { LinearData } from 'shared-types/index'; 3 | 4 | // square distance from a point to a segment 5 | function getSqSegDist( 6 | p: [number, number], 7 | p1: [number, number], 8 | p2: [number, number], 9 | ) { 10 | let x = p1[0]; 11 | let y = p1[1]; 12 | let dx = p2[0] - x; 13 | let dy = p2[1] - y; 14 | 15 | if (dx !== 0 || dy !== 0) { 16 | var t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy); 17 | 18 | if (t > 1) { 19 | x = p2[0]; 20 | y = p2[1]; 21 | } else if (t > 0) { 22 | x += dx * t; 23 | y += dy * t; 24 | } 25 | } 26 | 27 | dx = p[0] - x; 28 | dy = p[1] - y; 29 | 30 | return dx * dx + dy * dy; 31 | } 32 | 33 | function simplifyDPStep( 34 | points: LinearData, 35 | first: number, 36 | last: number, 37 | sqTolerance: number, 38 | simplified: LinearData, 39 | ) { 40 | let maxSqDist = sqTolerance; 41 | let index: number; 42 | 43 | for (let i = first + 1; i < last; i++) { 44 | const sqDist = getSqSegDist(points[i], points[first], points[last]); 45 | 46 | if (sqDist > maxSqDist) { 47 | index = i; 48 | maxSqDist = sqDist; 49 | } 50 | } 51 | 52 | if (maxSqDist > sqTolerance) { 53 | if (index! - first > 1) { 54 | simplifyDPStep(points, first, index!, sqTolerance, simplified); 55 | } 56 | 57 | simplified.push(points[index!]); 58 | 59 | if (last - index! > 1) { 60 | simplifyDPStep(points, index!, last, sqTolerance, simplified); 61 | } 62 | } 63 | } 64 | 65 | // simplification using Ramer-Douglas-Peucker algorithm 66 | function simplifyDouglasPeucker(points: LinearData, tolerance: number) { 67 | if (points.length <= 1) return points; 68 | const sqTolerance = tolerance * tolerance; 69 | const last = points.length - 1; 70 | const simplified: LinearData = [points[0]]; 71 | simplifyDPStep(points, 0, last, sqTolerance, simplified); 72 | simplified.push(points[last]); 73 | 74 | return simplified; 75 | } 76 | 77 | export default function useOptimizedPoints( 78 | fullPoints: Signal, 79 | simplify: Signal, 80 | round: Signal, 81 | ): Signal { 82 | return useComputed(() => { 83 | if (!fullPoints.value) return null; 84 | // x is represented as a percentage, so no point rounding less than 2 places 85 | const xRounding = Math.max(round.value, 2); 86 | 87 | return simplifyDouglasPeucker(fullPoints.value, simplify.value).map( 88 | ([x, y]) => [ 89 | Math.round(x * 10 ** xRounding) / 10 ** xRounding, 90 | Math.round(y * 10 ** round.value) / 10 ** round.value, 91 | ], 92 | ) as LinearData; 93 | }); 94 | } 95 | -------------------------------------------------------------------------------- /src/shared/App/useURLState.ts: -------------------------------------------------------------------------------- 1 | import { ReadonlySignal, useSignal, batch } from '@preact/signals'; 2 | import { useCallback, useMemo, useRef } from 'preact/hooks'; 3 | import { CodeType, State } from './types'; 4 | import { defaultJS, defaultSVG } from './demos'; 5 | import { getURLParamsFromState } from './utils'; 6 | 7 | interface UseURLStateReturn { 8 | codeType: ReadonlySignal; 9 | code: ReadonlySignal; 10 | simplify: ReadonlySignal; 11 | round: ReadonlySignal; 12 | update: (state: Partial) => void; 13 | } 14 | 15 | function getStateFromURL(): Partial { 16 | if (__PRERENDER__) return {}; 17 | 18 | const params = new URLSearchParams(location.search); 19 | const output: Partial = {}; 20 | 21 | if (params.has('codeType')) { 22 | const codeType = params.get('codeType'); 23 | output.codeType = codeType === 'js' ? CodeType.JS : CodeType.SVG; 24 | 25 | if (params.has('code')) { 26 | output.code = params.get('code')!; 27 | } 28 | } 29 | 30 | if (params.has('simplify')) { 31 | const simplify = Number(params.get('simplify')!); 32 | if (!isNaN(simplify)) output.simplify = simplify; 33 | } 34 | 35 | if (params.has('round')) { 36 | const round = Number(params.get('round')!); 37 | if (!isNaN(round)) output.round = round; 38 | } 39 | 40 | return output; 41 | } 42 | 43 | export default function useURLState(): UseURLStateReturn { 44 | const originalURLState = useMemo(getStateFromURL, []); 45 | const defaultCodeType = originalURLState.codeType || CodeType.JS; 46 | 47 | const codeType = useSignal(defaultCodeType); 48 | const code = useSignal( 49 | originalURLState.code || 50 | (defaultCodeType === CodeType.JS ? defaultJS.code : defaultSVG.code), 51 | ); 52 | 53 | const simplify = useSignal(originalURLState.simplify ?? 0.0017); 54 | const round = useSignal(originalURLState.round ?? 3); 55 | 56 | const urlChangeTimeout = useRef(null); 57 | 58 | const update = useCallback((newState: Partial) => { 59 | batch(() => { 60 | if (newState.codeType !== undefined) codeType.value = newState.codeType; 61 | if (newState.code !== undefined) code.value = newState.code; 62 | if (newState.simplify !== undefined) simplify.value = newState.simplify; 63 | if (newState.round !== undefined) round.value = newState.round; 64 | }); 65 | 66 | if (urlChangeTimeout.current !== null) { 67 | clearTimeout(urlChangeTimeout.current); 68 | } 69 | 70 | urlChangeTimeout.current = (setTimeout as typeof self.setTimeout)(() => { 71 | const newURL = new URL(location.href); 72 | newURL.search = getURLParamsFromState({ 73 | codeType: codeType.value, 74 | code: code.value, 75 | simplify: simplify.value, 76 | round: round.value, 77 | }).toString(); 78 | 79 | history.replaceState(null, '', newURL.toString()); 80 | }, 250); 81 | }, []); 82 | 83 | return useMemo( 84 | () => ({ 85 | codeType, 86 | code, 87 | simplify, 88 | round, 89 | update, 90 | }), 91 | [], 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/shared/App/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Signal, 3 | useComputed, 4 | useSignal, 5 | useSignalEffect, 6 | } from '@preact/signals'; 7 | import { CodeType, State } from './types'; 8 | import { useCallback, useEffect, useLayoutEffect } from 'preact/hooks'; 9 | 10 | export function doAbortable( 11 | signal: AbortSignal, 12 | callback: ( 13 | setAbortAction: (abortAction: () => void) => void, 14 | ) => R | Promise, 15 | ): Promise { 16 | if (signal.aborted) throw new DOMException('', 'AbortError'); 17 | let onAbort: () => void; 18 | let listener: () => void; 19 | let onAbortReturn: any; 20 | const setAbortAction = (c: () => void) => { 21 | onAbort = c; 22 | }; 23 | const promise = callback(setAbortAction); 24 | 25 | return Promise.race([ 26 | new Promise((_, reject) => { 27 | listener = () => { 28 | reject(new DOMException('', 'AbortError')); 29 | onAbortReturn = onAbort?.(); 30 | }; 31 | signal.addEventListener('abort', listener); 32 | }), 33 | promise, 34 | ]).finally(() => { 35 | signal.removeEventListener('abort', listener); 36 | return onAbortReturn; 37 | }); 38 | } 39 | 40 | export function logSignalUpdates(signals: { [name: string]: Signal }) { 41 | useSignalEffect(() => { 42 | console.log( 43 | Object.fromEntries( 44 | Object.entries(signals).map(([key, value]) => [key, value.value]), 45 | ), 46 | ); 47 | }); 48 | } 49 | 50 | export function getURLParamsFromState(state: Partial) { 51 | const params = new URLSearchParams(); 52 | 53 | if (state.codeType === CodeType.JS) { 54 | params.set('codeType', 'js'); 55 | } else if (state.codeType === CodeType.SVG) { 56 | params.set('codeType', 'svg'); 57 | } 58 | 59 | if (state.code) params.set('code', state.code); 60 | 61 | if (state.simplify !== undefined) { 62 | params.set('simplify', state.simplify.toString()); 63 | } 64 | 65 | if (state.round !== undefined) params.set('round', state.round.toString()); 66 | 67 | return params; 68 | } 69 | 70 | export function useElementSize(): [ 71 | refCallback: (el: Element | null) => void, 72 | sizeSignal: Signal<{ width: number; height: number } | null>, 73 | ] { 74 | const elSignal = useSignal(null); 75 | const sizeSignal = useSignal<{ width: number; height: number } | null>(null); 76 | const refCallback = useCallback((el: Element | null) => { 77 | elSignal.value = el; 78 | }, []); 79 | 80 | useSignalEffect(() => { 81 | if (!elSignal.value) return; 82 | 83 | const observer = new ResizeObserver((entries) => { 84 | const size = entries[0].contentBoxSize[0]; 85 | sizeSignal.value = { width: size.inlineSize, height: size.blockSize }; 86 | }); 87 | 88 | observer.observe(elSignal.value); 89 | return () => observer.disconnect(); 90 | }); 91 | 92 | return [refCallback, sizeSignal]; 93 | } 94 | 95 | export function useMatchMedia(query: string): Signal { 96 | const matchSignal = useSignal(false); 97 | 98 | useLayoutEffect(() => { 99 | const queryMatch = matchMedia(query); 100 | const listener = () => (matchSignal.value = queryMatch.matches); 101 | 102 | queryMatch.addEventListener('change', listener); 103 | listener(); 104 | 105 | return () => queryMatch.removeEventListener('change', listener); 106 | }, []); 107 | 108 | return matchSignal; 109 | } 110 | 111 | export function animateTo( 112 | element: HTMLElement, 113 | to: Keyframe[] | PropertyIndexedKeyframes, 114 | options: KeyframeAnimationOptions, 115 | ) { 116 | const anim = element.animate(to, { ...options, fill: 'both' }); 117 | anim.addEventListener('finish', () => { 118 | try { 119 | anim.commitStyles(); 120 | anim.cancel(); 121 | } catch (e) {} 122 | }); 123 | return anim; 124 | } 125 | 126 | export function animateFrom( 127 | element: HTMLElement, 128 | from: PropertyIndexedKeyframes, 129 | options: KeyframeAnimationOptions, 130 | ) { 131 | return element.animate( 132 | { ...from, offset: 0 }, 133 | { ...options, fill: 'backwards' }, 134 | ); 135 | } 136 | 137 | export const hideFromPrerender = __PRERENDER__ ? 'visibility: hidden' : ''; 138 | -------------------------------------------------------------------------------- /src/shared/missing-types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | /// 14 | 15 | declare const __PRERENDER__: boolean; 16 | -------------------------------------------------------------------------------- /src/static-build/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakearchibald/linear-easing-generator/17e22b88db2a94e6a798610ce406a04c7f9b8d27/src/static-build/assets/favicon.png -------------------------------------------------------------------------------- /src/static-build/assets/maskable-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakearchibald/linear-easing-generator/17e22b88db2a94e6a798610ce406a04c7f9b8d27/src/static-build/assets/maskable-icon.png -------------------------------------------------------------------------------- /src/static-build/assets/social-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakearchibald/linear-easing-generator/17e22b88db2a94e6a798610ce406a04c7f9b8d27/src/static-build/assets/social-icon.png -------------------------------------------------------------------------------- /src/static-build/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { h } from 'preact'; 14 | 15 | import { renderPage, writeFiles } from './utils'; 16 | import IndexPage from './pages/index'; 17 | import * as socialIconURL from 'img-url:static-build/assets/social-icon.png'; 18 | import * as maskableIconURL from 'img-url:static-build/assets/maskable-icon.png'; 19 | import { lookup as lookupMime } from 'mime-types'; 20 | import ProcessScript from './pages/process-script'; 21 | 22 | const manifestSize = ({ width, height }: { width: number; height: number }) => 23 | `${width}x${height}`; 24 | 25 | interface Output { 26 | [outputPath: string]: string; 27 | } 28 | 29 | const toOutput: Output = { 30 | 'index.html': renderPage(), 31 | '/process-script/index.html': renderPage(), 32 | 'manifest.json': JSON.stringify({ 33 | name: 'linear() easing generator', 34 | short_name: 'linear()', 35 | start_url: '/', 36 | display: 'standalone', 37 | orientation: 'any', 38 | background_color: '#fff', 39 | theme_color: '#009dff', 40 | icons: [ 41 | { 42 | src: maskableIconURL.default, 43 | type: lookupMime(maskableIconURL.default), 44 | sizes: manifestSize(maskableIconURL), 45 | purpose: 'maskable', 46 | }, 47 | { 48 | src: socialIconURL.default, 49 | type: lookupMime(socialIconURL.default), 50 | sizes: manifestSize(socialIconURL), 51 | }, 52 | ], 53 | description: 'Generate linear() easings from JavaScript and SVG', 54 | lang: 'en', 55 | }), 56 | }; 57 | 58 | writeFiles(toOutput); 59 | -------------------------------------------------------------------------------- /src/static-build/missing-types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | /// 14 | 15 | declare module 'prerender-css:' { 16 | const css: string; 17 | export default css; 18 | } 19 | 20 | declare module 'client-bundle:*' { 21 | const url: string; 22 | export default url; 23 | export const imports: string[]; 24 | /** Source for this script and all its dependencies */ 25 | export const allSrc: string; 26 | } 27 | -------------------------------------------------------------------------------- /src/static-build/pages/index/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | * You may obtain a copy of the License at 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * Unless required by applicable law or agreed to in writing, software 8 | * distributed under the License is distributed on an "AS IS" BASIS, 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | * See the License for the specific language governing permissions and 11 | * limitations under the License. 12 | */ 13 | import { h, FunctionalComponent } from 'preact'; 14 | 15 | import 'add-css:./styles.module.css'; 16 | import initialCss from 'prerender-css:'; 17 | import url, { allSrc, imports } from 'client-bundle:client/main'; 18 | import analyticsUrl from 'client-bundle:client/analytics/index.js'; 19 | import faviconURL from 'url:static-build/assets/favicon.png'; 20 | import socialImageURL from 'url:static-build/assets/social-icon.png'; 21 | // import ogImage from 'url:static-build/assets/icon-large-maskable.png'; 22 | import { escapeStyleScriptContent, siteOrigin } from 'static-build/utils'; 23 | import App from 'shared/App'; 24 | 25 | interface Props {} 26 | 27 | const Index: FunctionalComponent = () => ( 28 | 29 | 30 | Linear easing generator 31 | 32 | 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 47 | 48 | { 49 |