diff --git a/lib/css-plugin.js b/lib/css-plugin.js index 1e1ba6d1..b97bc1f9 100644 --- a/lib/css-plugin.js +++ b/lib/css-plugin.js @@ -41,6 +41,7 @@ const assetRe = new RegExp('/fake/path/to/asset/([^/]+)/', 'g'); const appendCssModule = '\0appendCss'; const appendCssSource = ` export default function appendCss(css) { + if (__PRERENDER__) return; const style = document.createElement('style'); style.textContent = css; document.head.append(style); diff --git a/lib/initial-css-plugin.js b/lib/initial-css-plugin.js index 055a2740..54461570 100644 --- a/lib/initial-css-plugin.js +++ b/lib/initial-css-plugin.js @@ -30,7 +30,7 @@ export default function initialCssPlugin() { async load(id) { if (id !== initialCssModule) return; - const matches = await globP('shared/initial-app/**/*.css', { + const matches = await globP('shared/prerendered-app/**/*.css', { nodir: true, cwd: path.join(process.cwd(), 'src'), }); diff --git a/src/client/initial-app/App/index.tsx b/src/client/initial-app/App/index.tsx index e4700312..36d92a8a 100644 --- a/src/client/initial-app/App/index.tsx +++ b/src/client/initial-app/App/index.tsx @@ -1,16 +1,16 @@ import type { FileDropEvent } from 'file-drop-element'; -import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar'; -import type { SnackOptions } from 'shared/initial-app/custom-els/snack-bar'; +import type SnackBarElement from 'shared/custom-els/snack-bar'; +import type { SnackOptions } from 'shared/custom-els/snack-bar'; import { h, Component } from 'preact'; -import { linkRef } from 'shared/initial-app/util'; +import { linkRef } from 'shared/prerendered-app/util'; import * as style from './style.css'; import 'add-css:./style.css'; import 'file-drop-element'; -import 'shared/initial-app/custom-els/snack-bar'; -import Intro from 'shared/initial-app/Intro'; -import 'shared/initial-app/custom-els/loading-spinner'; +import 'shared/custom-els/snack-bar'; +import Intro from 'shared/prerendered-app/Intro'; +import 'shared/custom-els/loading-spinner'; const ROUTE_EDITOR = '/editor'; diff --git a/src/client/initial-app/App/style.css b/src/client/initial-app/App/style.css index d23b5483..0b143c35 100644 --- a/src/client/initial-app/App/style.css +++ b/src/client/initial-app/App/style.css @@ -24,8 +24,8 @@ right: 10px; bottom: 10px; border: 2px dashed #fff; - background-color: rgba(88, 116, 88, 0.2); - border-color: rgba(65, 129, 65, 0.5); + background-color: rgba(0, 0, 0, 0.1); + border-color: var(--pink); border-radius: 10px; opacity: 0; transform: scale(0.95); diff --git a/src/client/initial-app/custom-els/missing-types.d.ts b/src/client/initial-app/custom-els/missing-types.d.ts index 37aa4e00..d2c41034 100644 --- a/src/client/initial-app/custom-els/missing-types.d.ts +++ b/src/client/initial-app/custom-els/missing-types.d.ts @@ -1,5 +1,5 @@ -/// -/// +/// +/// import type { FileDropElement, FileDropEvent } from 'file-drop-element'; interface FileDropAttributes extends preact.JSX.HTMLAttributes { diff --git a/src/client/lazy-app/Compress/Options/Range/index.tsx b/src/client/lazy-app/Compress/Options/Range/index.tsx index e4ed66b9..da6e37e2 100644 --- a/src/client/lazy-app/Compress/Options/Range/index.tsx +++ b/src/client/lazy-app/Compress/Options/Range/index.tsx @@ -3,7 +3,7 @@ import * as style from './style.css'; import 'add-css:./style.css'; import RangeInputElement from './custom-els/RangeInput'; import './custom-els/RangeInput'; -import { linkRef } from 'shared/initial-app/util'; +import { linkRef } from 'shared/prerendered-app/util'; interface Props extends preact.JSX.HTMLAttributes {} interface State {} diff --git a/src/client/lazy-app/Compress/Output/index.tsx b/src/client/lazy-app/Compress/Output/index.tsx index 94a653a9..c459c2b3 100644 --- a/src/client/lazy-app/Compress/Output/index.tsx +++ b/src/client/lazy-app/Compress/Output/index.tsx @@ -18,7 +18,7 @@ import { twoUpHandle } from './custom-els/TwoUp/styles.css'; import type { PreprocessorState } from '../../feature-meta'; import { cleanSet } from '../../util/clean-modify'; import type { SourceImage } from '../../Compress'; -import { linkRef } from 'shared/initial-app/util'; +import { linkRef } from 'shared/prerendered-app/util'; interface Props { source?: SourceImage; diff --git a/src/client/lazy-app/Compress/Output/style.css b/src/client/lazy-app/Compress/Output/style.css index ab56f174..269a04a0 100644 --- a/src/client/lazy-app/Compress/Output/style.css +++ b/src/client/lazy-app/Compress/Output/style.css @@ -1,5 +1,5 @@ .output { - composes: abs-fill from '../../../../shared/initial-app/util.css'; + composes: abs-fill from global; &::before { content: ''; @@ -19,12 +19,12 @@ } .two-up { - composes: abs-fill from '../../../../shared/initial-app/util.css'; + composes: abs-fill from global; --accent-color: var(--button-fg); } .pinch-zoom { - composes: abs-fill from '../../../../shared/initial-app/util.css'; + composes: abs-fill from global; outline: none; display: flex; justify-content: center; diff --git a/src/client/lazy-app/Compress/Results/index.tsx b/src/client/lazy-app/Compress/Results/index.tsx index 57f8e73d..647c5fce 100644 --- a/src/client/lazy-app/Compress/Results/index.tsx +++ b/src/client/lazy-app/Compress/Results/index.tsx @@ -8,7 +8,7 @@ import { CopyAcrossIcon, CopyAcrossIconProps, } from 'client/lazy-app/icons'; -import 'shared/initial-app/custom-els/loading-spinner'; +import 'shared/custom-els/loading-spinner'; import { SourceImage } from '../'; interface Props { diff --git a/src/client/lazy-app/Compress/Results/style.css b/src/client/lazy-app/Compress/Results/style.css index 1918c841..8d56f36f 100644 --- a/src/client/lazy-app/Compress/Results/style.css +++ b/src/client/lazy-app/Compress/Results/style.css @@ -124,7 +124,7 @@ .copy-to-other { grid-row: 1; grid-column: copy-button; - composes: unbutton from '../../../../shared/initial-app/util.css'; + composes: unbutton from global; composes: download; background: #656565; diff --git a/src/client/lazy-app/Compress/index.tsx b/src/client/lazy-app/Compress/index.tsx index bcd84242..0b56d8fb 100644 --- a/src/client/lazy-app/Compress/index.tsx +++ b/src/client/lazy-app/Compress/index.tsx @@ -30,7 +30,7 @@ import './custom-els/MultiPanel'; import Results from './Results'; import WorkerBridge from '../worker-bridge'; import { resize } from 'features/processors/resize/client'; -import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar'; +import type SnackBarElement from 'shared/custom-els/snack-bar'; import { CopyAcrossIconProps, ExpandIcon } from '../icons'; export type OutputType = EncoderType | 'identity'; diff --git a/src/client/lazy-app/sw-bridge/index.ts b/src/client/lazy-app/sw-bridge/index.ts index 13322685..d500955a 100644 --- a/src/client/lazy-app/sw-bridge/index.ts +++ b/src/client/lazy-app/sw-bridge/index.ts @@ -1,4 +1,4 @@ -import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar'; +import type SnackBarElement from 'shared/custom-els/snack-bar'; import { get, set } from 'idb-keyval'; diff --git a/src/client/missing-types.d.ts b/src/client/missing-types.d.ts index a176b0a8..d25cdf43 100644 --- a/src/client/missing-types.d.ts +++ b/src/client/missing-types.d.ts @@ -11,7 +11,7 @@ * limitations under the License. */ /// -/// +/// interface Navigator { readonly standalone: boolean; @@ -25,23 +25,3 @@ declare module 'service-worker:*' { } declare module 'preact/debug' {} - -interface ResizeObserverCallback { - (entries: ResizeObserverEntry[], observer: ResizeObserver): void; -} - -interface ResizeObserverEntry { - readonly target: Element; - readonly contentRect: DOMRectReadOnly; -} - -interface ResizeObserver { - observe(target: Element): void; - unobserve(target: Element): void; - disconnect(): void; -} - -declare var ResizeObserver: { - prototype: ResizeObserver; - new (callback: ResizeObserverCallback): ResizeObserver; -}; diff --git a/src/features/processors/resize/client/index.tsx b/src/features/processors/resize/client/index.tsx index 5f048a8b..6b82da5b 100644 --- a/src/features/processors/resize/client/index.tsx +++ b/src/features/processors/resize/client/index.tsx @@ -22,7 +22,7 @@ import { inputFieldChecked, } from 'client/lazy-app/util'; import * as style from 'client/lazy-app/Compress/Options/style.css'; -import { linkRef } from 'shared/initial-app/util'; +import { linkRef } from 'shared/prerendered-app/util'; import Select from 'client/lazy-app/Compress/Options/Select'; import Expander from 'client/lazy-app/Compress/Options/Expander'; import Checkbox from 'client/lazy-app/Compress/Options/Checkbox'; diff --git a/src/shared/initial-app/custom-els/loading-spinner/index.ts b/src/shared/custom-els/loading-spinner/index.ts similarity index 98% rename from src/shared/initial-app/custom-els/loading-spinner/index.ts rename to src/shared/custom-els/loading-spinner/index.ts index 3abe3108..2985b6d9 100644 --- a/src/shared/initial-app/custom-els/loading-spinner/index.ts +++ b/src/shared/custom-els/loading-spinner/index.ts @@ -1,4 +1,5 @@ import * as styles from './styles.css'; +import 'add-css:./styles.css'; // So it doesn't cause an error when running in node const HTMLEl = ((__PRERENDER__ diff --git a/src/shared/initial-app/custom-els/loading-spinner/missing-types.d.ts b/src/shared/custom-els/loading-spinner/missing-types.d.ts similarity index 100% rename from src/shared/initial-app/custom-els/loading-spinner/missing-types.d.ts rename to src/shared/custom-els/loading-spinner/missing-types.d.ts diff --git a/src/shared/initial-app/custom-els/loading-spinner/styles.css b/src/shared/custom-els/loading-spinner/styles.css similarity index 100% rename from src/shared/initial-app/custom-els/loading-spinner/styles.css rename to src/shared/custom-els/loading-spinner/styles.css diff --git a/src/shared/initial-app/custom-els/snack-bar/index.ts b/src/shared/custom-els/snack-bar/index.ts similarity index 98% rename from src/shared/initial-app/custom-els/snack-bar/index.ts rename to src/shared/custom-els/snack-bar/index.ts index 8e530c46..4c6617f7 100644 --- a/src/shared/initial-app/custom-els/snack-bar/index.ts +++ b/src/shared/custom-els/snack-bar/index.ts @@ -1,4 +1,5 @@ import * as style from './styles.css'; +import 'add-css:./styles.css'; // So it doesn't cause an error when running in node const HTMLEl = ((__PRERENDER__ diff --git a/src/shared/initial-app/custom-els/snack-bar/missing-types.d.ts b/src/shared/custom-els/snack-bar/missing-types.d.ts similarity index 100% rename from src/shared/initial-app/custom-els/snack-bar/missing-types.d.ts rename to src/shared/custom-els/snack-bar/missing-types.d.ts diff --git a/src/shared/initial-app/custom-els/snack-bar/styles.css b/src/shared/custom-els/snack-bar/styles.css similarity index 100% rename from src/shared/initial-app/custom-els/snack-bar/styles.css rename to src/shared/custom-els/snack-bar/styles.css diff --git a/src/shared/initial-app/Intro/imgs/demos/icon-demo-artwork.jpg b/src/shared/initial-app/Intro/imgs/demos/icon-demo-artwork.jpg deleted file mode 100644 index 79d05f02..00000000 Binary files a/src/shared/initial-app/Intro/imgs/demos/icon-demo-artwork.jpg and /dev/null differ diff --git a/src/shared/initial-app/Intro/imgs/demos/icon-demo-device-screen.jpg b/src/shared/initial-app/Intro/imgs/demos/icon-demo-device-screen.jpg deleted file mode 100644 index 5f140f0f..00000000 Binary files a/src/shared/initial-app/Intro/imgs/demos/icon-demo-device-screen.jpg and /dev/null differ diff --git a/src/shared/initial-app/Intro/imgs/demos/icon-demo-large-photo.jpg b/src/shared/initial-app/Intro/imgs/demos/icon-demo-large-photo.jpg deleted file mode 100644 index f418b268..00000000 Binary files a/src/shared/initial-app/Intro/imgs/demos/icon-demo-large-photo.jpg and /dev/null differ diff --git a/src/shared/initial-app/Intro/imgs/demos/icon-demo-logo.png b/src/shared/initial-app/Intro/imgs/demos/icon-demo-logo.png deleted file mode 100644 index 44ff9216..00000000 Binary files a/src/shared/initial-app/Intro/imgs/demos/icon-demo-logo.png and /dev/null differ diff --git a/src/shared/initial-app/Intro/index.tsx b/src/shared/initial-app/Intro/index.tsx deleted file mode 100644 index 10b01cbb..00000000 --- a/src/shared/initial-app/Intro/index.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { h, Component } from 'preact'; - -import { linkRef } from 'shared/initial-app/util'; -import '../custom-els/loading-spinner'; - -import logo from 'url:./imgs/logo.svg'; -import largePhoto from 'url:./imgs/demos/demo-large-photo.jpg'; -import artwork from 'url:./imgs/demos/demo-artwork.jpg'; -import deviceScreen from 'url:./imgs/demos/demo-device-screen.png'; -import largePhotoIcon from 'url:./imgs/demos/icon-demo-large-photo.jpg'; -import artworkIcon from 'url:./imgs/demos/icon-demo-artwork.jpg'; -import deviceScreenIcon from 'url:./imgs/demos/icon-demo-device-screen.jpg'; -import logoIcon from 'url:./imgs/demos/icon-demo-logo.png'; -import * as style from './style.css'; -import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar'; -import 'shared/initial-app/custom-els/snack-bar'; - -const demos = [ - { - description: 'Large photo (2.8mb)', - filename: 'photo.jpg', - url: largePhoto, - iconUrl: largePhotoIcon, - }, - { - description: 'Artwork (2.9mb)', - filename: 'art.jpg', - url: artwork, - iconUrl: artworkIcon, - }, - { - description: 'Device screen (1.6mb)', - filename: 'pixel3.png', - url: deviceScreen, - iconUrl: deviceScreenIcon, - }, - { - description: 'SVG icon (13k)', - filename: 'squoosh.svg', - url: logo, - iconUrl: logoIcon, - }, -]; - -const installButtonSource = 'introInstallButton-Purple'; - -interface Props { - onFile?: (file: File) => void; - showSnack?: SnackBarElement['showSnackbar']; -} -interface State { - fetchingDemoIndex?: number; - beforeInstallEvent?: BeforeInstallPromptEvent; -} - -export default class Intro extends Component { - state: State = {}; - private fileInput?: HTMLInputElement; - private installingViaButton = false; - - constructor() { - super(); - - if (__PRERENDER__) return; - // Listen for beforeinstallprompt events, indicating Squoosh is installable. - window.addEventListener( - 'beforeinstallprompt', - this.onBeforeInstallPromptEvent, - ); - - // Listen for the appinstalled event, indicating Squoosh has been installed. - window.addEventListener('appinstalled', this.onAppInstalled); - } - - private resetFileInput = () => { - this.fileInput!.value = ''; - }; - - private onFileChange = (event: Event): void => { - const fileInput = event.target as HTMLInputElement; - const file = fileInput.files && fileInput.files[0]; - if (!file) return; - this.resetFileInput(); - this.props.onFile!(file); - }; - - private onButtonClick = () => { - this.fileInput!.click(); - }; - - private onDemoClick = async (index: number, event: Event) => { - try { - this.setState({ fetchingDemoIndex: index }); - const demo = demos[index]; - const blob = await fetch(demo.url).then((r) => r.blob()); - - // Firefox doesn't like content types like 'image/png; charset=UTF-8', which Webpack's dev - // server returns. https://bugzilla.mozilla.org/show_bug.cgi?id=1497925. - const type = /[^;]*/.exec(blob.type)![0]; - const file = new File([blob], demo.filename, { type }); - this.props.onFile!(file); - } catch (err) { - this.setState({ fetchingDemoIndex: undefined }); - this.props.showSnack!("Couldn't fetch demo image"); - } - }; - - private onBeforeInstallPromptEvent = (event: BeforeInstallPromptEvent) => { - // Don't show the mini-infobar on mobile - event.preventDefault(); - - // Save the beforeinstallprompt event so it can be called later. - this.setState({ beforeInstallEvent: event }); - - // Log the event. - const gaEventInfo = { - eventCategory: 'pwa-install', - eventAction: 'promo-shown', - nonInteraction: true, - }; - ga('send', 'event', gaEventInfo); - }; - - private onInstallClick = async (event: Event) => { - // Get the deferred beforeinstallprompt event - const beforeInstallEvent = this.state.beforeInstallEvent; - // If there's no deferred prompt, bail. - if (!beforeInstallEvent) return; - - this.installingViaButton = true; - - // Show the browser install prompt - beforeInstallEvent.prompt(); - - // Wait for the user to accept or dismiss the install prompt - const { outcome } = await beforeInstallEvent.userChoice; - // Send the analytics data - const gaEventInfo = { - eventCategory: 'pwa-install', - eventAction: 'promo-clicked', - eventLabel: installButtonSource, - eventValue: outcome === 'accepted' ? 1 : 0, - }; - ga('send', 'event', gaEventInfo); - - // If the prompt was dismissed, we aren't going to install via the button. - if (outcome === 'dismissed') { - this.installingViaButton = false; - } - }; - - private onAppInstalled = () => { - // We don't need the install button, if it's shown - this.setState({ beforeInstallEvent: undefined }); - - // Don't log analytics if page is not visible - if (document.hidden) { - return; - } - - // Try to get the install, if it's not set, use 'browser' - const source = this.installingViaButton ? installButtonSource : 'browser'; - ga('send', 'event', 'pwa-install', 'installed', source); - - // Clear the install method property - this.installingViaButton = false; - }; - - render({}: Props, { fetchingDemoIndex, beforeInstallEvent }: State) { - return ( -
-
-
-
- Squoosh -
-
-

- Drag & drop or{' '} - - -

-

Or try one of these:

-
    - {demos.map((demo, i) => ( -
  • - -
  • - ))} -
-
- {beforeInstallEvent && ( - - )} - -
- ); - } -} diff --git a/src/shared/initial-app/Intro/style.css b/src/shared/initial-app/Intro/style.css deleted file mode 100644 index 6e936e5c..00000000 --- a/src/shared/initial-app/Intro/style.css +++ /dev/null @@ -1,228 +0,0 @@ -@font-face { - font-family: 'intro-text'; - font-style: normal; - font-weight: 300; - font-display: block; - /* This only contains the chars for "Drag & drop or" */ - src: url('data:font/woff2;base64,d09GMgABAAAAAAXcAA4AAAAACowAAAWJAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg2gcMAZgAGwRCAqIQIcnCxYAATYCJAMoBCAFgwAHIBvqCFEU84FMI2Xh/P3g+Tfn532yQ/IgYz4BrJyhtkkZwFBYAZ49sI63e5v/NnqzIfbADyE0qxOqK8ESLoNNdULHihxbW0W86/qHEk4wT/eHShPRZJYYqUGkQdLSWCeSemZBzwpKyX/LRoAhMEQhqCFBw5RHNCc4hbVn35FsxtTXVHYyo7miu5VN2AW1fwzVauRgXnIGo2IWsYdViUoLu6mms5VFAn+SeQ4eBazfj7QodrMt4oyQHaGADEPRpTbDqJaoTENNK6DpOralUszf6gI/QsAhWZSMKVOirikSJxZRLBVD0S4mB0kTBRwopjZ/mt/2/25+bcSipgiHRmwiFI1g+XhwlshyEAsbJzGiGH+U5whHNgiXooplafI1rMFbmIqjGAPhmcSkVFxeu9hw87aXsGyL+dPE05qUpK2WyaVQcZVW+aDmw3aalLJKNmQORcpZYtBIuTrncN4xXoVZY617TBSsx2T1DHgGU6u4etE04wha1GEwjVkEaDttOrl1FCOwUMxgHnuooJo62ukcWEuc1/aT+dZ8b142t5tbzc3mGnP1EJqVTEGMYTjG14YxtGEEG+0E2axhe6Oa1E8UrDHDFjhTRywYNWrU9JHTlw7RmaslkrrGcTJ+znW4EzzP0zovE4Z5d0hqVhBobftBIKkwL09SOv3hhCuv1Dp9taTeCJ2Mj3KDT8iDng5DkWzPw/UdP8idNDkMnUyOwEauwnYLQeLC7GskNe72QKe97AmuA42E5FjfyYTM+HTdQ+Xqb+q4JvptyKZN1w47qMMwL58fyKZM1U6NXgWlOFdxx7DpXHDTz4UB89WMK3HH3uY7mavFopGF+u36lGlqZsL4ugmbqvZxveycMO+a4uyN3o7GT2qdHpfr6W++kNTn1crdx7Z+FW7PfffTmfnXV/2ivsh5UX93zdlzct6QlSuHSumG3oGNNT9/m9yXnDcnKfsmDx8xUaoKi+uvGs99H2ieUJUg8bTnVwQcDd/SPKwYWDUv+QkpT6MulMrcPTXNWYnIowxvoiwnX+opTMkvzOMGgpNpqnK32CNVwCnassw0BwQwTa0rLS3m1DfIoxx5PIE8SvEmSk3pHSWZiRVKjOOQSylJSHGXkhT/u/tg/Vm9UZQcS59TGb1qjcuuT0925iaaU1vaWpZJM4ukqWWlrdWSIcVNlOImvnrzLn53UpnSLzbGT5lUlpTiKiPJFEmyqywFLtOhcaYJkWkaGe/oGBlnmiIiIiKYpqHxLmdaWg5JpxxHSXpajsuVlkPSvb1JelqOC0pubbAn2A2UsDdYmTmjvbVlgTRhVBSSxpbF1nZD+jvkUR4rcJeSFBp2d19SUsVW5DjkUkoSoITHJ7iJEpZnZaL4OiF7g92DN1mz8b1RiM9RDk9ps9pcanamlnj2ftqbJpHJ0wpkRn2+RJ6qsGflpYrPnxG6A4r9zqGY3qCcqDuhsWGQhoXpQ0663cWFM4qNR0Jxj1R0UBT36pahMneH4NYV27jElOeyAAAACACAABy4uvGyOsj21Y9h3gIA3PuxYAYAuC/7vftf7L+PXunMAQDwBQIAAAIA5vR/HwCvOQ//TzLL7cPIHUC0zMI5v7+tHiVfzWOeSrJKZbFabWGNSnJE+jmsnmTjTZm6kBi9r0aLgm8qNk6t67ATuPlEitG+g+E7in1GMYxCxmIF9YzNJK7lRoSPc6PCD+8fxhp+YjdttDNAJw3UUU83M1jFClaylkpkU08NVZqkh0oaaBLPnaCTNhqpoaok2UkPZqy/JyfpKnVLkhrq6KGZCjpZxTJWqN9uJofD5HGMzSXHLaVbOmuTSnOp6cTQgJlaB6oF7RIITul8N+1sYjnL6aJqqoZ2inaxDIY2s2zwlXUs5zj7OPJmAPao+ZhVHy0A') - format('woff2'); -} - -@font-face { - font-family: 'intro-text'; - font-style: normal; - font-weight: 500; - font-display: block; - /* Only contains the chars for "select an image" */ - src: url('data:font/woff2;base64,d09GMgABAAAAAAXMAA4AAAAACwQAAAV5AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbg1IcMAZgAGwRCAqJUId/CxoAATYCJAMwBCAFgnAHIBskCcjEh6dNff8Ou/9Tj9VZGnUhJeqFzWGiVVOkxkTthr9f6kWkRdsBbkIj3YuLaloFZWr7aBg22z7IOoWqBWCW5cZU3GBrh0n+dAcBYAUlzYHAzWTYchqKXEyAT0zOLIS1qqm+B8q2ur4OhEMy0PHHUH8KklaSr8T0mp/EU7kRvXlI1E09HXA1qLN8Djxa0AsSDOg3cJARb9mtQJoSK3gvEn372/gcAigg/gOnbsT/MYv491GTReW4rJC5LA+h5FFclF6QQgoZ5Kx7GbsuGeytUgFClkOomY2Gdake3m9HegkHieAx/a0hBTALsy4jvpxBcnFXUnjC+2ZS5zHnDeEaJVwi+ZWqzOm4Uvgy4k6kGv4kFDVkfjk1gkVRRkk2zlo42PBbRJmG30cClJQjak7BnfQqHza4ITKftQZ/ZMUaEiyy1+mYCh4clKhDA5rQglZ0oG9jw+qiNvT+SfxIXCeuFdeIq8VV4gpxyRaGOl0JiChiCocfc7e93DwZIPvWgPiZJLcJugxyjW7UQyl4TJk6dWqYU7Cn0WQiWnNJCdGeprqjW63fpVS3mKy4YGZ6I3ya4nbIVgM1mwkpNEBzixlNxfPmH7owvdE4973OM9quvk11dwvnzDUy/Zn5S5Ywpn/PeqXBQI2m4lna05CRtsI6+GIENjS9K4jWRHUGYA2ozdZm2Smmf0DI3aqpeNbsJfxe7YdFmcZAn5gXLCFa2/Umqiu017APFhMZ0rfQp4sJX0ZrJ+n9UtAljr5VYWb6oj1MrpvX3qe6u8WRJg0bj7aPkDOa7m+E0Oa9Y8eY/gbRbr+efH7hcO49bMd28fbDVHcUmm3XkozQGKjeBHSJ4TQnI879LIFmF2v/BJuEQlffJPfE9oKayS/PsPE44fvM4MsBESxbuEEV39d5pw6oW4vD6S1WQC3UpSbHNbK0Jikl0bphSs+0CGW8Ew4Kzw7zarmcVz873JHTFhKYay18R8vY0ozPiHPAGyROAqlW5fLj5+HbWBn9TpgekKsOy8N+4dlFfL9i/Nk3+gY1bwzZUAvLVNiFpvqHRenetSoVrgn2obGtPsltEVxEeHJAQFhyBIcHT9rDvTJm06e0TLgase2gd2RffGJNg0o1zrdRyi9s1bYE5bi85cK+o/nUwvBR5+jweEBaSMoCub29fEFISmib9Dn5yl5kVFpoGrPQSmZhafQ7WimttNCH7Cktohb6kFpoEfIsdDF7SgvZTbaY3mKFyLXQh+wpzWE3mUNQQkWnR+lgiW9Afkunej49Nz3sYlI8XFTRdkNhUR5d6h4oOpJc8OjcItMXVqoHW2fSW6ycWuiM8NDYoICouLAZ9BYrwwmhycvKlt4Q8hUlCV5nZ7vOm2ut2rizcFpuWpSrT3K1Z3xinbuHnXBTyGAljV/XzHZaNGu6y7vLDziMpIyUdOBBRXXlxznUQiOoheZsZk9njG8er1mNmz2eOCQ3x9BbLP+Zxt+VrbEz9aWxmimRvyl4/sumyoM/nw+LNzV4/uP0/9T/P5f08cvhl38USAS/xTYs2fL/VNFF0vd+SVsRB/khPwW4SCi5SHhx8fDgVPAiAqIJRQL/EuK5bsRzNnAiezD1i7u2VyHHAKRU+E2YUaA5DUsE2ZfbApAmsJcxjBwmQ2Xk4Y2BqZJ+oxSzsNEogz1O3/xkBMKEBHSiC8PoQStaoEIflPCHL/wQBCUKoUITlMhHP+olt5ojykUPOrEQTWgY+f449KMPKnSiB72jGrLQhEZO24925LtbkG5DndTkD2/4um+NQBEyUIJsRBxX24tcDGmbWtGJjq05jhzTaMgCcR+6EA4f+KAXDay5u6RsL7xJsm3w3mzvFvggB8nI/AYBdVnxNvx/2wA=') - format('woff2'); -} - -@keyframes fade-in { - from { - opacity: 0; - } -} - -.intro { - display: grid; - grid-template-rows: 1fr min-content; - align-items: center; - background: rgba(255, 255, 255, 0.25); - text-align: center; - font-size: 2rem; - -webkit-overflow-scrolling: touch; - overflow: auto; - padding: 20px 0 0; - height: 100%; - box-sizing: border-box; - overscroll-behavior: contain; - position: relative; -} - -.logo-container { - position: relative; - padding-top: 100%; -} - -.logo-sizer { - width: 90%; - max-width: 52vh; - margin: 0 auto; -} - -.logo { - composes: abs-fill from '../util.css'; - pointer-events: none; -} - -.open-image-guide { - font: 300 11vw intro-text, sans-serif; - margin-bottom: 0; - - @media (min-width: 460px) { - font-size: 50.6px; - padding: 0 40px; - } -} - -.select-button { - composes: unbutton from '../util.css'; - font-weight: 500; - color: #5d509e; - - &:hover, - &:focus { - text-decoration: underline; - } -} - -.hide { - display: none; -} - -.demos { - display: block; - padding: 0; - border-top: 1px solid #e8e8e8; - margin: 0 auto; - - @media (min-width: 400px) { - display: grid; - grid-template-columns: 1fr 1fr; - } - - @media (min-width: 580px) { - border-top: none; - width: 523px; - } - - @media (min-width: 900px) { - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; - width: 773px; - } -} - -.demo-item { - background: #fff; - display: flex; - border-bottom: 1px solid #e8e8e8; - - @media (min-width: 580px) { - border: 1px solid #e8e8e8; - border-radius: 4px; - margin: 3px; - } -} - -.demo-button { - composes: unbutton from '../util.css'; - flex: 1; - - &:hover, - &:focus { - background: #f5f5f5; - } -} - -.demo { - display: flex; - align-items: center; - padding: 7px; - font-size: 1.3rem; -} - -.demo-img-container { - overflow: hidden; - display: block; - width: 47px; - background: #ccc; - border-radius: 3px; - flex: 0 0 auto; -} - -.demo-img-aspect { - position: relative; - padding-top: 100%; -} - -.demo-icon { - composes: abs-fill from '../util.css'; - pointer-events: none; -} - -.demo-description { - display: flex; - align-items: center; - justify-content: flex-start; - text-align: left; - flex: 1; - padding: 0 10px; -} - -.demo-loading { - composes: abs-fill from '../util.css'; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - animation: fade-in 300ms ease-in-out; -} - -.demo-loading-spinner { - --color: #fff; -} - -.install-button { - composes: unbutton from '../util.css'; - - &:hover, - &:focus { - background: #504488; - } - - background: #5d509e; - border: 1px solid #e8e8e8; - color: #fff; - padding: 14px; - font-size: 1.3rem; - - position: absolute; - top: 1rem; - right: 1rem; - - animation: fade-in 0.3s ease-in-out; -} - -@keyframes fade-in { - from { - opacity: 0; - } -} - -.related-links { - display: flex; - padding: 0; - justify-content: center; - font-size: 1.3rem; - - & li { - display: block; - border-left: 1px solid #000; - padding: 0 0.6em; - - &:first-child { - border-left: none; - } - } - - & a:link { - color: #5d509e; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } -} diff --git a/src/shared/initial-app/util.css b/src/shared/initial-app/util.css deleted file mode 100644 index b8c7ba48..00000000 --- a/src/shared/initial-app/util.css +++ /dev/null @@ -1,22 +0,0 @@ -.abs-fill { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - box-sizing: border-box; - contain: strict; -} - -.unbutton { - cursor: pointer; - background: none; - border: none; - font: inherit; - padding: 0; - margin: 0; - - &:focus { - outline: none; - } -} diff --git a/src/shared/missing-types.d.ts b/src/shared/missing-types.d.ts index baf404ec..0320ec90 100644 --- a/src/shared/missing-types.d.ts +++ b/src/shared/missing-types.d.ts @@ -13,3 +13,24 @@ /// declare const __PRERENDER__: boolean; + +type ResizeObserverCallback = ( + entries: ResizeObserverEntry[], + observer: ResizeObserver, +) => void; + +interface ResizeObserverEntry { + readonly target: Element; + readonly contentRect: DOMRectReadOnly; +} + +interface ResizeObserver { + observe(target: Element): void; + unobserve(target: Element): void; + disconnect(): void; +} + +declare var ResizeObserver: { + prototype: ResizeObserver; + new (callback: ResizeObserverCallback): ResizeObserver; +}; diff --git a/src/shared/prerendered-app/Intro/blob-anim/index.ts b/src/shared/prerendered-app/Intro/blob-anim/index.ts new file mode 100644 index 00000000..8b1d9e58 --- /dev/null +++ b/src/shared/prerendered-app/Intro/blob-anim/index.ts @@ -0,0 +1,417 @@ +import * as style from '../style.css'; +import { startBlobs } from './meta'; + +/** + * Control point x,y - point x,y - control point x,y + */ +export type BlobPoint = [number, number, number, number, number, number]; + +const maxPointDistance = 0.25; + +function randomisePoint(point: BlobPoint): BlobPoint { + const distance = Math.random() * maxPointDistance; + const angle = Math.random() * Math.PI * 2; + const xShift = Math.sin(angle) * distance; + const yShift = Math.cos(angle) * distance; + return [ + point[0] + xShift, + point[1] + yShift, + point[2] + xShift, + point[3] + yShift, + point[4] + xShift, + point[5] + yShift, + ]; +} + +function easeInOutQuad(x: number): number { + return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; +} + +function easeInExpo(x: number): number { + return x === 0 ? 0 : Math.pow(2, 10 * x - 10); +} + +const rand = (min: number, max: number) => Math.random() * (max - min) + min; + +interface CircleBlobPointState { + basePoint: BlobPoint; + pos: number; + duration: number; + startPoint: BlobPoint; + endPoint: BlobPoint; +} + +/** Bezier points for a seven point circle, to 3 decimal places */ +const sevenPointCircle: BlobPoint[] = [ + [-0.304, -1, 0, -1, 0.304, -1], + [0.592, -0.861, 0.782, -0.623, 0.972, -0.386], + [1.043, -0.074, 0.975, 0.223, 0.907, 0.519], + [0.708, 0.769, 0.434, 0.901, 0.16, 1.033], + [-0.16, 1.033, -0.434, 0.901, -0.708, 0.769], + [-0.907, 0.519, -0.975, 0.223, -1.043, -0.074], + [-0.972, -0.386, -0.782, -0.623, -0.592, -0.861], +]; + +/* +// Should it be needed, here's how the above was created: +function createBezierCirclePoints(points: number): BlobPoint[] { + const anglePerPoint = 360 / points; + const matrix = new DOMMatrix(); + const point = new DOMPoint(); + const controlDistance = (4 / 3) * Math.tan(Math.PI / (2 * points)); + return Array.from({ length: points }, (_, i) => { + point.x = -controlDistance; + point.y = -1; + const cp1 = point.matrixTransform(matrix); + point.x = 0; + point.y = -1; + const p = point.matrixTransform(matrix); + point.x = controlDistance; + point.y = -1; + const cp2 = point.matrixTransform(matrix); + const basePoint: BlobPoint = [cp1.x, cp1.y, p.x, p.y, cp2.x, cp2.y]; + matrix.rotateSelf(0, 0, anglePerPoint); + return basePoint; + }); +} +*/ + +interface CircleBlobOptions { + minDuration?: number; + maxDuration?: number; + startPoints?: BlobPoint[]; +} + +class CircleBlob { + private animStates: CircleBlobPointState[]; + private minDuration: number; + private maxDuration: number; + private points: BlobPoint[]; + + constructor( + basePoints: BlobPoint[], + { + startPoints = basePoints.map((point) => randomisePoint(point)), + minDuration = 4000, + maxDuration = 11000, + }: CircleBlobOptions = {}, + ) { + this.points = startPoints; + this.minDuration = minDuration; + this.maxDuration = maxDuration; + this.animStates = basePoints.map((basePoint, i) => ({ + basePoint, + pos: 0, + duration: rand(minDuration, maxDuration), + startPoint: startPoints[i], + endPoint: randomisePoint(basePoint), + })); + } + + advance(timeDelta: number): void { + this.points = this.animStates.map((animState) => { + animState.pos += timeDelta / animState.duration; + if (animState.pos >= 1) { + animState.startPoint = animState.endPoint; + animState.pos = 0; + animState.duration = rand(this.minDuration, this.maxDuration); + animState.endPoint = randomisePoint(animState.basePoint); + } + const eased = easeInOutQuad(animState.pos); + + const point = animState.startPoint.map((startPoint, i) => { + const endPoint = animState.endPoint[i]; + return (endPoint - startPoint) * eased + startPoint; + }) as BlobPoint; + + return point; + }); + } + + draw(ctx: CanvasRenderingContext2D) { + const points = this.points; + ctx.beginPath(); + ctx.moveTo(points[0][2], points[0][3]); + + for (let i = 0; i < points.length; i++) { + const nextI = i === points.length - 1 ? 0 : i + 1; + ctx.bezierCurveTo( + points[i][4], + points[i][5], + points[nextI][0], + points[nextI][1], + points[nextI][2], + points[nextI][3], + ); + } + + ctx.closePath(); + ctx.fill(); + } +} + +const centralBlobsRotationTime = 120000; + +class CentralBlobs { + private rotatePos: number = 0; + private blobs = Array.from( + { length: 4 }, + (_, i) => new CircleBlob(sevenPointCircle, { startPoints: startBlobs[i] }), + ); + + advance(timeDelta: number) { + this.rotatePos = + (this.rotatePos + timeDelta / centralBlobsRotationTime) % 1; + for (const blob of this.blobs) blob.advance(timeDelta); + } + + draw(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number) { + ctx.save(); + ctx.translate(x, y); + ctx.scale(radius, radius); + ctx.rotate(Math.PI * 2 * this.rotatePos); + for (const blob of this.blobs) blob.draw(ctx); + ctx.restore(); + } +} + +const bgBlobsMinRadius = 7; +const bgBlobsMaxRadius = 60; +const bgBlobsMinAlpha = 0.2; +const bgBlobsMaxAlpha = 0.8; +const bgBlobsPerPx = 0.000025; +const bgBlobsMinSpinTime = 20000; +const bgBlobsMaxSpinTime = 60000; +const bgBlobsMinVelocity = 0.0015; +const bgBlobsMaxVelocity = 0.007; +const gravityVelocityMultiplier = 15; +const gravityStartDistance = 300; + +interface BackgroundBlob { + blob: CircleBlob; + velocity: number; + spinTime: number; + alpha: number; + alphaMultiplier: number; + rotatePos: number; + radius: number; + x: number; + y: number; +} + +const bgBlobsAlphaTime = 2000; + +class BackgroundBlobs { + private bgBlobs: BackgroundBlob[] = []; + private overallAlphaPos = 0; + + constructor(bounds: DOMRect) { + const blobs = Math.round(bounds.width * bounds.height * bgBlobsPerPx); + this.bgBlobs = Array.from({ length: blobs }, () => { + const radiusPos = easeInExpo(Math.random()); + + return { + blob: new CircleBlob(sevenPointCircle, { + minDuration: 2000, + maxDuration: 5000, + }), + // Velocity is based on the size + velocity: + (1 - radiusPos) * (bgBlobsMaxVelocity - bgBlobsMinVelocity) + + bgBlobsMinVelocity, + alpha: + Math.random() ** 3 * (bgBlobsMaxAlpha - bgBlobsMinAlpha) + + bgBlobsMinAlpha, + alphaMultiplier: 1, + spinTime: rand(bgBlobsMinSpinTime, bgBlobsMaxSpinTime), + rotatePos: 0, + radius: + radiusPos * (bgBlobsMaxRadius - bgBlobsMinRadius) + bgBlobsMinRadius, + x: Math.random() * bounds.width, + y: Math.random() * bounds.height, + }; + }); + } + + advance( + timeDelta: number, + bounds: DOMRect, + targetX: number, + targetY: number, + targetRadius: number, + ) { + if (this.overallAlphaPos !== 1) { + this.overallAlphaPos = Math.min( + 1, + this.overallAlphaPos + timeDelta / bgBlobsAlphaTime, + ); + } + for (const bgBlob of this.bgBlobs) { + bgBlob.blob.advance(timeDelta); + let dist = Math.hypot(bgBlob.x - targetX, bgBlob.y - targetY); + bgBlob.rotatePos = (bgBlob.rotatePos + timeDelta / bgBlob.spinTime) % 1; + + if (dist < 10) { + // Move the circle out to a random edge + switch (Math.floor(Math.random() * 4)) { + case 0: // top + bgBlob.x = Math.random() * bounds.width; + bgBlob.y = -(bgBlob.radius * (1 + maxPointDistance)); + break; + case 1: // left + bgBlob.x = -(bgBlob.radius * (1 + maxPointDistance)); + bgBlob.y = Math.random() * bounds.height; + break; + case 2: // bottom + bgBlob.x = Math.random() * bounds.width; + bgBlob.y = bounds.height + bgBlob.radius * (1 + maxPointDistance); + break; + case 3: // right + bgBlob.x = bounds.width + bgBlob.radius * (1 + maxPointDistance); + bgBlob.y = Math.random() * bounds.height; + break; + } + } + dist = Math.hypot(bgBlob.x - targetX, bgBlob.y - targetY); + const velocity = + dist > gravityStartDistance + ? bgBlob.velocity + : ((1 - dist / gravityStartDistance) * + (gravityVelocityMultiplier - 1) + + 1) * + bgBlob.velocity; + const shiftDist = velocity * timeDelta; + const direction = Math.atan2(targetX - bgBlob.x, targetY - bgBlob.y); + const xShift = Math.sin(direction) * shiftDist; + const yShift = Math.cos(direction) * shiftDist; + bgBlob.x += xShift; + bgBlob.y += yShift; + bgBlob.alphaMultiplier = Math.min(dist / targetRadius, 1); + } + } + + draw(ctx: CanvasRenderingContext2D) { + const overallAlpha = easeInOutQuad(this.overallAlphaPos); + + for (const bgBlob of this.bgBlobs) { + ctx.save(); + ctx.globalAlpha = bgBlob.alpha * bgBlob.alphaMultiplier * overallAlpha; + ctx.translate(bgBlob.x, bgBlob.y); + ctx.scale(bgBlob.radius, bgBlob.radius); + ctx.rotate(Math.PI * 2 * bgBlob.rotatePos); + bgBlob.blob.draw(ctx); + ctx.restore(); + } + } +} + +const deltaMultiplierStep = 0.01; + +export function startBlobAnim(canvas: HTMLCanvasElement) { + let lastTime: number; + const ctx = canvas.getContext('2d')!; + const centralBlobs = new CentralBlobs(); + let backgroundBlobs: BackgroundBlobs; + const loadImgEl = document.querySelector('.' + style.loadImg)!; + let hasFocus = document.hasFocus(); + let deltaMultiplier = hasFocus ? 1 : 0; + let animating = true; + + const visibilityListener = () => { + // 'Pause time' while page is hidden + if (document.visibilityState === 'visible') lastTime = performance.now(); + }; + const focusListener = () => { + hasFocus = true; + if (!animating) startAnim(); + }; + const blurListener = () => { + hasFocus = false; + }; + + new ResizeObserver(() => { + // Redraw for new canvas size + if (!animating) drawFrame(0); + }).observe(canvas); + + addEventListener('focus', focusListener); + addEventListener('blur', blurListener); + document.addEventListener('visibilitychange', visibilityListener); + + function destruct() { + removeEventListener('focus', focusListener); + removeEventListener('blur', blurListener); + document.removeEventListener('visibilitychange', visibilityListener); + } + + function drawFrame(delta: number) { + const canvasBounds = canvas.getBoundingClientRect(); + canvas.width = canvasBounds.width * devicePixelRatio; + canvas.height = canvasBounds.height * devicePixelRatio; + const loadImgBounds = loadImgEl.getBoundingClientRect(); + const computedStyles = getComputedStyle(canvas); + const blobPink = computedStyles.getPropertyValue('--blob-pink'); + const loadImgCenterX = + loadImgBounds.left - canvasBounds.left + loadImgBounds.width / 2; + const loadImgCenterY = + loadImgBounds.top - canvasBounds.top + loadImgBounds.height / 2; + const loadImgRadius = loadImgBounds.height / 2 / (1 + maxPointDistance); + + ctx.scale(devicePixelRatio, devicePixelRatio); + + if (!backgroundBlobs) backgroundBlobs = new BackgroundBlobs(canvasBounds); + backgroundBlobs.advance( + delta, + canvasBounds, + loadImgCenterX, + loadImgCenterY, + loadImgRadius, + ); + centralBlobs.advance(delta); + + ctx.globalAlpha = Number( + computedStyles.getPropertyValue('--center-blob-opacity'), + ); + ctx.fillStyle = blobPink; + + backgroundBlobs.draw(ctx); + centralBlobs.draw(ctx, loadImgCenterX, loadImgCenterY, loadImgRadius); + } + + function frame(time: number) { + // Stop the loop if the canvas is gone + if (!canvas.isConnected) { + destruct(); + return; + } + + // Be kind: If the window isn't focused, bring the animation to a stop. + if (!hasFocus) { + // Bring the anim to a slow stop + deltaMultiplier = Math.max(0, deltaMultiplier - deltaMultiplierStep); + if (deltaMultiplier === 0) { + animating = false; + return; + } + } else if (deltaMultiplier !== 1) { + deltaMultiplier = Math.min(1, deltaMultiplier + deltaMultiplierStep); + } + + const delta = (time - lastTime) * deltaMultiplier; + lastTime = time; + + drawFrame(delta); + + requestAnimationFrame(frame); + } + + function startAnim() { + animating = true; + requestAnimationFrame((time: number) => { + lastTime = time; + frame(time); + }); + } + + startAnim(); +} diff --git a/src/shared/prerendered-app/Intro/blob-anim/meta.ts b/src/shared/prerendered-app/Intro/blob-anim/meta.ts new file mode 100644 index 00000000..66ed610e --- /dev/null +++ b/src/shared/prerendered-app/Intro/blob-anim/meta.ts @@ -0,0 +1,41 @@ +import type { BlobPoint } from '.'; + +/** Start points, for the shape we use in prerender */ +export const startBlobs: BlobPoint[][] = [ + [ + [-0.232, -1.029, 0.073, -1.029, 0.377, -1.029], + [0.565, -1.098, 0.755, -0.86, 0.945, -0.622], + [0.917, -0.01, 0.849, 0.286, 0.782, 0.583], + [0.85, 0.687, 0.576, 0.819, 0.302, 0.951], + [-0.198, 1.009, -0.472, 0.877, -0.746, 0.745], + [-0.98, 0.513, -1.048, 0.216, -1.116, -0.08], + [-0.964, -0.395, -0.774, -0.633, -0.584, -0.871], + ], + [ + [-0.505, -1.109, -0.201, -1.109, 0.104, -1.109], + [0.641, -0.684, 0.831, -0.446, 1.02, -0.208], + [1.041, 0.034, 0.973, 0.331, 0.905, 0.628], + [0.734, 0.794, 0.46, 0.926, 0.186, 1.058], + [-0.135, 0.809, -0.409, 0.677, -0.684, 0.545], + [-0.935, 0.404, -1.002, 0.108, -1.07, -0.189], + [-0.883, -0.402, -0.693, -0.64, -0.503, -0.878], + ], + [ + [-0.376, -1.168, -0.071, -1.168, 0.233, -1.168], + [0.732, -0.956, 0.922, -0.718, 1.112, -0.48], + [1.173, 0.027, 1.105, 0.324, 1.038, 0.621], + [0.707, 0.81, 0.433, 0.943, 0.159, 1.075], + [-0.096, 1.135, -0.37, 1.003, -0.644, 0.871], + [-0.86, 0.457, -0.927, 0.161, -0.995, -0.136], + [-0.87, -0.516, -0.68, -0.754, -0.49, -0.992], + ], + [ + [-0.309, -0.998, -0.004, -0.998, 0.3, -0.998], + [0.535, -0.852, 0.725, -0.614, 0.915, -0.376], + [1.05, -0.09, 0.982, 0.207, 0.915, 0.504], + [0.659, 0.807, 0.385, 0.939, 0.111, 1.071], + [-0.178, 1.048, -0.452, 0.916, -0.727, 0.784], + [-0.942, 0.582, -1.009, 0.285, -1.077, -0.011], + [-1.141, -0.335, -0.951, -0.573, -0.761, -0.811], + ], +]; diff --git a/src/shared/initial-app/Intro/imgs/demos/demo-artwork.jpg b/src/shared/prerendered-app/Intro/imgs/demos/demo-artwork.jpg similarity index 100% rename from src/shared/initial-app/Intro/imgs/demos/demo-artwork.jpg rename to src/shared/prerendered-app/Intro/imgs/demos/demo-artwork.jpg diff --git a/src/shared/initial-app/Intro/imgs/demos/demo-device-screen.png b/src/shared/prerendered-app/Intro/imgs/demos/demo-device-screen.png similarity index 100% rename from src/shared/initial-app/Intro/imgs/demos/demo-device-screen.png rename to src/shared/prerendered-app/Intro/imgs/demos/demo-device-screen.png diff --git a/src/shared/initial-app/Intro/imgs/demos/demo-large-photo.jpg b/src/shared/prerendered-app/Intro/imgs/demos/demo-large-photo.jpg similarity index 100% rename from src/shared/initial-app/Intro/imgs/demos/demo-large-photo.jpg rename to src/shared/prerendered-app/Intro/imgs/demos/demo-large-photo.jpg diff --git a/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-artwork.jpg b/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-artwork.jpg new file mode 100644 index 00000000..aae2b9ad Binary files /dev/null and b/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-artwork.jpg differ diff --git a/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-device-screen.jpg b/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-device-screen.jpg new file mode 100644 index 00000000..f7bfce16 Binary files /dev/null and b/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-device-screen.jpg differ diff --git a/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-large-photo.jpg b/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-large-photo.jpg new file mode 100644 index 00000000..9bdb4c08 Binary files /dev/null and b/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-large-photo.jpg differ diff --git a/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-logo.png b/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-logo.png new file mode 100644 index 00000000..0571ba44 Binary files /dev/null and b/src/shared/prerendered-app/Intro/imgs/demos/icon-demo-logo.png differ diff --git a/src/shared/prerendered-app/Intro/imgs/github-logo.svg b/src/shared/prerendered-app/Intro/imgs/github-logo.svg new file mode 100644 index 00000000..5d6cd659 --- /dev/null +++ b/src/shared/prerendered-app/Intro/imgs/github-logo.svg @@ -0,0 +1 @@ + diff --git a/src/shared/prerendered-app/Intro/imgs/logo-with-text.svg b/src/shared/prerendered-app/Intro/imgs/logo-with-text.svg new file mode 100644 index 00000000..8ea594f2 --- /dev/null +++ b/src/shared/prerendered-app/Intro/imgs/logo-with-text.svg @@ -0,0 +1 @@ +Squoosh \ No newline at end of file diff --git a/src/shared/initial-app/Intro/imgs/logo.svg b/src/shared/prerendered-app/Intro/imgs/logo.svg similarity index 100% rename from src/shared/initial-app/Intro/imgs/logo.svg rename to src/shared/prerendered-app/Intro/imgs/logo.svg diff --git a/src/shared/prerendered-app/Intro/index.tsx b/src/shared/prerendered-app/Intro/index.tsx new file mode 100644 index 00000000..9e2be760 --- /dev/null +++ b/src/shared/prerendered-app/Intro/index.tsx @@ -0,0 +1,372 @@ +import { h, Component } from 'preact'; + +import { linkRef } from 'shared/prerendered-app/util'; +import '../../custom-els/loading-spinner'; +import logo from 'url:./imgs/logo.svg'; +import githubLogo from 'url:./imgs/github-logo.svg'; +import largePhoto from 'url:./imgs/demos/demo-large-photo.jpg'; +import artwork from 'url:./imgs/demos/demo-artwork.jpg'; +import deviceScreen from 'url:./imgs/demos/demo-device-screen.png'; +import largePhotoIcon from 'url:./imgs/demos/icon-demo-large-photo.jpg'; +import artworkIcon from 'url:./imgs/demos/icon-demo-artwork.jpg'; +import deviceScreenIcon from 'url:./imgs/demos/icon-demo-device-screen.jpg'; +import logoIcon from 'url:./imgs/demos/icon-demo-logo.png'; +import logoWithText from 'url:./imgs/logo-with-text.svg'; +import * as style from './style.css'; +import type SnackBarElement from 'shared/custom-els/snack-bar'; +import 'shared/custom-els/snack-bar'; +import { startBlobs } from './blob-anim/meta'; + +const demos = [ + { + description: 'Large photo', + size: '2.8mb', + filename: 'photo.jpg', + url: largePhoto, + iconUrl: largePhotoIcon, + }, + { + description: 'Artwork', + size: '2.9mb', + filename: 'art.jpg', + url: artwork, + iconUrl: artworkIcon, + }, + { + description: 'Device screen', + size: '1.6mb', + filename: 'pixel3.png', + url: deviceScreen, + iconUrl: deviceScreenIcon, + }, + { + description: 'SVG icon', + size: '13k', + filename: 'squoosh.svg', + url: logo, + iconUrl: logoIcon, + }, +]; + +const blobAnimImport = + !__PRERENDER__ && matchMedia('(prefers-reduced-motion: reduce)').matches + ? undefined + : import('./blob-anim'); +const installButtonSource = 'introInstallButton-Purple'; +const supportsClipboardAPI = + !__PRERENDER__ && navigator.clipboard && navigator.clipboard.read; + +async function getImageClipboardItem( + items: ClipboardItem[], +): Promise { + for (const item of items) { + const type = item.types.find((type) => type.startsWith('image/')); + if (type) return item.getType(type); + } +} + +interface Props { + onFile?: (file: File) => void; + showSnack?: SnackBarElement['showSnackbar']; +} +interface State { + fetchingDemoIndex?: number; + beforeInstallEvent?: BeforeInstallPromptEvent; + showBlobSVG: boolean; +} + +export default class Intro extends Component { + state: State = { + showBlobSVG: true, + }; + private fileInput?: HTMLInputElement; + private blobCanvas?: HTMLCanvasElement; + private installingViaButton = false; + + componentDidMount() { + // Listen for beforeinstallprompt events, indicating Squoosh is installable. + window.addEventListener( + 'beforeinstallprompt', + this.onBeforeInstallPromptEvent, + ); + + // Listen for the appinstalled event, indicating Squoosh has been installed. + window.addEventListener('appinstalled', this.onAppInstalled); + + if (blobAnimImport) { + blobAnimImport.then((module) => { + this.setState( + { + showBlobSVG: false, + }, + () => module.startBlobAnim(this.blobCanvas!), + ); + }); + } + } + + componentWillUnmount() { + window.removeEventListener( + 'beforeinstallprompt', + this.onBeforeInstallPromptEvent, + ); + window.removeEventListener('appinstalled', this.onAppInstalled); + } + + private onFileChange = (event: Event): void => { + const fileInput = event.target as HTMLInputElement; + const file = fileInput.files && fileInput.files[0]; + if (!file) return; + this.fileInput!.value = ''; + this.props.onFile!(file); + }; + + private onOpenClick = () => { + this.fileInput!.click(); + }; + + private onDemoClick = async (index: number, event: Event) => { + try { + this.setState({ fetchingDemoIndex: index }); + const demo = demos[index]; + const blob = await fetch(demo.url).then((r) => r.blob()); + const file = new File([blob], demo.filename, { type: blob.type }); + this.props.onFile!(file); + } catch (err) { + this.setState({ fetchingDemoIndex: undefined }); + this.props.showSnack!("Couldn't fetch demo image"); + } + }; + + private onBeforeInstallPromptEvent = (event: BeforeInstallPromptEvent) => { + // Don't show the mini-infobar on mobile + event.preventDefault(); + + // Save the beforeinstallprompt event so it can be called later. + this.setState({ beforeInstallEvent: event }); + + // Log the event. + const gaEventInfo = { + eventCategory: 'pwa-install', + eventAction: 'promo-shown', + nonInteraction: true, + }; + ga('send', 'event', gaEventInfo); + }; + + private onInstallClick = async (event: Event) => { + // Get the deferred beforeinstallprompt event + const beforeInstallEvent = this.state.beforeInstallEvent; + // If there's no deferred prompt, bail. + if (!beforeInstallEvent) return; + + this.installingViaButton = true; + + // Show the browser install prompt + beforeInstallEvent.prompt(); + + // Wait for the user to accept or dismiss the install prompt + const { outcome } = await beforeInstallEvent.userChoice; + // Send the analytics data + const gaEventInfo = { + eventCategory: 'pwa-install', + eventAction: 'promo-clicked', + eventLabel: installButtonSource, + eventValue: outcome === 'accepted' ? 1 : 0, + }; + ga('send', 'event', gaEventInfo); + + // If the prompt was dismissed, we aren't going to install via the button. + if (outcome === 'dismissed') { + this.installingViaButton = false; + } + }; + + private onAppInstalled = () => { + // We don't need the install button, if it's shown + this.setState({ beforeInstallEvent: undefined }); + + // Don't log analytics if page is not visible + if (document.hidden) return; + + // Try to get the install, if it's not set, use 'browser' + const source = this.installingViaButton ? installButtonSource : 'browser'; + ga('send', 'event', 'pwa-install', 'installed', source); + + // Clear the install method property + this.installingViaButton = false; + }; + + private onPasteClick = async () => { + let clipboardItems: ClipboardItem[]; + + try { + clipboardItems = await navigator.clipboard.read(); + } catch (err) { + this.props.showSnack!(`No permission to access clipboard`); + return; + } + + const blob = await getImageClipboardItem(clipboardItems); + + if (!blob) { + this.props.showSnack!(`No image found in the clipboard`); + return; + } + + this.props.onFile!(new File([blob], 'image.unknown')); + }; + + render( + {}: Props, + { fetchingDemoIndex, beforeInstallEvent, showBlobSVG }: State, + ) { + return ( +
+ +
+ {!__PRERENDER__ && ( + + )} +

+ Squoosh +

+
+ {showBlobSVG && ( + + {startBlobs.map((points) => ( + { + const nextI = i === points.length - 1 ? 0 : i + 1; + let d = ''; + if (i === 0) { + d += `M${point[2]} ${point[3]}`; + } + return ( + d + + `C${point[4]} ${point[5]} ${points[nextI][0]} ${points[nextI][1]} ${points[nextI][2]} ${points[nextI][3]}` + ); + }) + .join('')} + /> + ))} + + )} +
+ +
+ Drop OR{' '} + {supportsClipboardAPI ? ( + + ) : ( + 'Paste' + )} +
+
+
+
+
+ + + + +
+

+ Or try one of these: +

+
    + {demos.map((demo, i) => ( +
  • + +
  • + ))} +
+
+
+ + {beforeInstallEvent && ( + + )} +
+ ); + } +} diff --git a/src/shared/initial-app/Intro/missing-types.d.ts b/src/shared/prerendered-app/Intro/missing-types.d.ts similarity index 87% rename from src/shared/initial-app/Intro/missing-types.d.ts rename to src/shared/prerendered-app/Intro/missing-types.d.ts index a35205c5..6ef3d345 100644 --- a/src/shared/initial-app/Intro/missing-types.d.ts +++ b/src/shared/prerendered-app/Intro/missing-types.d.ts @@ -29,3 +29,12 @@ interface BeforeInstallPromptEvent extends Event { interface WindowEventMap { beforeinstallprompt: BeforeInstallPromptEvent; } + +interface ClipboardItem { + types: string[]; + getType(type: string): Promise; +} + +interface Clipboard { + read(): Promise; +} diff --git a/src/shared/prerendered-app/Intro/style.css b/src/shared/prerendered-app/Intro/style.css new file mode 100644 index 00000000..dea2ffe4 --- /dev/null +++ b/src/shared/prerendered-app/Intro/style.css @@ -0,0 +1,243 @@ +.intro { + composes: abs-fill from global; + -webkit-overflow-scrolling: touch; + overflow: auto; + overscroll-behavior: contain; + display: grid; + grid-template-rows: 1fr max-content max-content; + font-size: 1.2rem; + color: var(--dark-text); +} + +.blob-canvas { + composes: abs-fill from global; + width: 100%; + height: 100%; +} + +.hide { + display: none; +} + +.main { + min-height: 541px; + display: grid; + grid-template-rows: max-content max-content; + justify-items: center; + position: relative; + --blob-pink: var(--hot-pink); + --center-blob-opacity: 0.3; + + @media (min-width: 600px) { + min-height: 688px; + } +} + +.logo-container { + margin: 5rem 0 1rem; +} + +.logo { + transform: translate(-1%, 0); + width: 189px; + height: auto; +} + +.load-img { + position: relative; + color: var(--white); + font-style: italic; + font-size: 1.2rem; +} + +.blob-svg { + composes: abs-fill from global; + width: 100%; + height: 100%; + fill: var(--blob-pink); + + & path { + opacity: var(--center-blob-opacity); + } +} + +.load-img-content { + position: relative; + --size: 29rem; + max-width: var(--size); + width: 100vw; + height: var(--size); + display: grid; + grid-template-rows: max-content max-content; + justify-items: center; + align-content: center; + gap: 0.7rem; + + @media (min-width: 600px) { + --size: 36rem; + } +} + +.load-btn { + composes: unbutton from global; +} + +.load-icon { + --size: 5rem; + width: var(--size); + height: var(--size); + fill: var(--white); + transform: translate(4.3%, -1%); +} + +.paste-btn { + composes: unbutton from global; + text-decoration: underline; + font: inherit; + color: inherit; +} + +.demos-container { + position: relative; + background: var(--deep-blue); + padding-bottom: 5.2vw; +} + +.top-wave { + position: absolute; + left: 0; + right: 0; + bottom: 100%; +} + +.main-wave { + fill: var(--deep-blue); +} + +.sub-wave { + fill: var(--light-blue); +} + +.footer { + position: relative; + background: var(--light-gray); +} + +.footer-wave { + fill: var(--light-gray); +} + +.content-padding { + padding: 2rem; +} + +.footer-items { + display: grid; + justify-content: end; + grid-auto-columns: max-content; + grid-auto-flow: column; + align-items: center; + gap: 4rem; +} + +.footer-link { + text-decoration: none; + color: inherit; +} + +.footer-link-with-logo { + composes: footer-link; + display: grid; + grid-template-columns: 1.8em max-content; + align-items: center; + gap: 0.6em; + + img { + width: 100%; + height: auto; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } +} + +.install-btn { + composes: unbutton from global; + position: absolute; + top: 1rem; + right: 1rem; + background: var(--deep-blue); + border-radius: 0.4em; + color: var(--white); + padding: 0.5em 1em; + font-size: 1.6rem; + animation: fade-in 600ms ease-in-out; +} + +.demo-title { + color: var(--white); + margin: 0; + font-size: 2rem; + text-align: center; +} + +.demos { + display: grid; + gap: 3rem; + justify-items: center; + justify-content: center; + padding: 0; + margin: 3rem auto; + --demo-size: 80px; + grid-template-columns: repeat(auto-fit, var(--demo-size)); + + @media (min-width: 740px) { + --demo-size: 100px; + gap: 6rem; + } + + & > li { + display: block; + } +} + +.demo-size { + background: var(--dim-blue); + border-radius: 1000px; + color: var(--white); + width: max-content; + padding: 0.5rem 1.2rem; + margin: 0.7rem auto 0; +} + +.demo-icon-container { + border-radius: var(--demo-size); + position: relative; + overflow: hidden; +} +.demo-icon { + width: var(--demo-size); + height: var(--demo-size); + display: block; +} +.demo-loader { + composes: abs-fill from global; + background: rgba(0, 0, 0, 0.5); + display: grid; + justify-content: center; + align-content: center; + animation: fade-in 600ms ease-in-out; + + & > loading-spinner { + --color: var(--white); + } +} + +.drop-text { + @media (max-width: 599px) { + display: none; + } +} diff --git a/src/shared/prerendered-app/colors.css b/src/shared/prerendered-app/colors.css new file mode 100644 index 00000000..1f0ce297 --- /dev/null +++ b/src/shared/prerendered-app/colors.css @@ -0,0 +1,19 @@ +html { + --pink: #ff3385; + --hot-pink: #ff0066; + --white: #fff; + --dim-blue: #0a7bcc; + --deep-blue: #09f; + --light-blue: #76c8ff; + --light-gray: #eaeaea; + --dark-text: #343a3e; + + /* Old stuff: */ + --gray-dark: rgba(0, 0, 0, 0.8); + + --button-fg-color: 95, 180, 228; + --button-fg: rgb(95, 180, 228); + + --negative: rgb(207, 113, 127); + --positive: rgb(149, 212, 159); +} diff --git a/src/shared/prerendered-app/util.css b/src/shared/prerendered-app/util.css new file mode 100644 index 00000000..fc48b374 --- /dev/null +++ b/src/shared/prerendered-app/util.css @@ -0,0 +1,24 @@ +:global { + .abs-fill { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + contain: strict; + } + + .unbutton { + cursor: pointer; + background: none; + border: none; + font: inherit; + padding: 0; + margin: 0; + + &:focus { + outline: none; + } + } +} diff --git a/src/shared/initial-app/util.ts b/src/shared/prerendered-app/util.ts similarity index 100% rename from src/shared/initial-app/util.ts rename to src/shared/prerendered-app/util.ts diff --git a/src/static-build/index.tsx b/src/static-build/index.tsx index cf4d4a3d..4d104375 100644 --- a/src/static-build/index.tsx +++ b/src/static-build/index.tsx @@ -29,7 +29,7 @@ const toOutput: Output = { display: 'standalone', orientation: 'any', background_color: '#fff', - theme_color: '#f78f21', + theme_color: '#ff3385', icons: [ { src: iconLarge, diff --git a/src/static-build/missing-types.d.ts b/src/static-build/missing-types.d.ts index 5c48298c..1de4bdfc 100644 --- a/src/static-build/missing-types.d.ts +++ b/src/static-build/missing-types.d.ts @@ -11,7 +11,7 @@ * limitations under the License. */ /// -/// +/// declare module 'client-bundle:*' { const url: string; diff --git a/src/static-build/pages/index/base.css b/src/static-build/pages/index/base.css index 71399504..43713da8 100644 --- a/src/static-build/pages/index/base.css +++ b/src/static-build/pages/index/base.css @@ -25,8 +25,8 @@ body { height: 100%; padding: 0; margin: 0; - font: 12px/1.3 system-ui, -apple-system, BlinkMacSystemFont, Roboto, Helvetica, - sans-serif; + font: 12px/1.3 'HelveticaNeue-Light', 'Helvetica Neue Light', 'Helvetica Neue', + Helvetica, Arial, 'Lucida Grande', sans-serif; overflow: hidden; overscroll-behavior: none; contain: strict; @@ -34,16 +34,6 @@ body { background-size: 20px 20px; } -:root { - --gray-dark: rgba(0, 0, 0, 0.8); - - --button-fg-color: 95, 180, 228; - --button-fg: rgb(95, 180, 228); - - --negative: rgb(207, 113, 127); - --positive: rgb(149, 212, 159); -} - :global(#app) { position: absolute; left: 0; diff --git a/src/static-build/pages/index/index.tsx b/src/static-build/pages/index/index.tsx index c2c4ca6f..e1ab1a44 100644 --- a/src/static-build/pages/index/index.tsx +++ b/src/static-build/pages/index/index.tsx @@ -17,7 +17,7 @@ import initialCss from 'initial-css:'; import { allSrc } from 'client-bundle:client/initial-app'; import favicon from 'url:static-build/assets/favicon.ico'; import { escapeStyleScriptContent } from 'static-build/utils'; -import Intro from 'shared/initial-app/Intro'; +import Intro from 'shared/prerendered-app/Intro'; interface Props {}