New homepage (#861)
* Paste button * Logo and animated blobs * Predictable initial blob position * Initial blob shape * Update canvas on resize if not updating every frame * lol * Get styles from CSS * Background blobs * Fade into the center * Get initial focus * Pause time while page is hidden * Footer * Optimise amount of initial CSS * More CSS optimisation * Install button * Home page with demo loading * Tweak size * Replace thumbnails * Responsive demo section * Responsive main section * Remove debug stuff * Fix prerender SVG size * Changes from feedback * Blob nudges (#872) * more smaller blobs * less blobs that are practically invisible * more dynamic speed range and stronger gravity * Reverting resize observer change The content rect is different to getBoundingClientRect Co-authored-by: Adam Argyle <atom@argyleink.com>
@ -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);
|
||||
|
@ -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'),
|
||||
});
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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);
|
||||
|
@ -1,5 +1,5 @@
|
||||
/// <reference path="../../../shared/initial-app/custom-els/snack-bar/missing-types.d.ts" />
|
||||
/// <reference path="../../../shared/initial-app/custom-els/loading-spinner/missing-types.d.ts" />
|
||||
/// <reference path="../../../shared/custom-els/snack-bar/missing-types.d.ts" />
|
||||
/// <reference path="../../../shared/custom-els/loading-spinner/missing-types.d.ts" />
|
||||
import type { FileDropElement, FileDropEvent } from 'file-drop-element';
|
||||
|
||||
interface FileDropAttributes extends preact.JSX.HTMLAttributes {
|
||||
|
@ -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 {}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
22
src/client/missing-types.d.ts
vendored
@ -11,7 +11,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
/// <reference path="../../missing-types.d.ts" />
|
||||
/// <reference path="../shared/initial-app/Intro/missing-types.d.ts" />
|
||||
/// <reference path="../shared/prerendered-app/Intro/missing-types.d.ts" />
|
||||
|
||||
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;
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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__
|
@ -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__
|
Before Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 1.5 KiB |
@ -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<Props, State> {
|
||||
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 (
|
||||
<div class={style.intro}>
|
||||
<div>
|
||||
<div class={style.logoSizer}>
|
||||
<div class={style.logoContainer}>
|
||||
<img
|
||||
src={logo}
|
||||
class={style.logo}
|
||||
alt="Squoosh"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class={style.openImageGuide}>
|
||||
Drag & drop or{' '}
|
||||
<button class={style.selectButton} onClick={this.onButtonClick}>
|
||||
select an image
|
||||
</button>
|
||||
<input
|
||||
class={style.hide}
|
||||
ref={linkRef(this, 'fileInput')}
|
||||
type="file"
|
||||
onChange={this.onFileChange}
|
||||
/>
|
||||
</p>
|
||||
<p>Or try one of these:</p>
|
||||
<ul class={style.demos}>
|
||||
{demos.map((demo, i) => (
|
||||
<li key={demo.url} class={style.demoItem}>
|
||||
<button
|
||||
class={style.demoButton}
|
||||
onClick={this.onDemoClick.bind(this, i)}
|
||||
>
|
||||
<div class={style.demo}>
|
||||
<div class={style.demoImgContainer}>
|
||||
<div class={style.demoImgAspect}>
|
||||
<img
|
||||
class={style.demoIcon}
|
||||
src={demo.iconUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
{fetchingDemoIndex === i && (
|
||||
<div class={style.demoLoading}>
|
||||
<loading-spinner class={style.demoLoadingSpinner} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class={style.demoDescription}>{demo.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{beforeInstallEvent && (
|
||||
<button
|
||||
type="button"
|
||||
class={style.installButton}
|
||||
onClick={this.onInstallClick}
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
<ul class={style.relatedLinks}>
|
||||
<li>
|
||||
<a href="https://github.com/GoogleChromeLabs/squoosh/">
|
||||
View the code
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/GoogleChromeLabs/squoosh/issues">
|
||||
Report a bug
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/GoogleChromeLabs/squoosh/blob/dev/README.md#privacy">
|
||||
Privacy
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
21
src/shared/missing-types.d.ts
vendored
@ -13,3 +13,24 @@
|
||||
/// <reference path="../../missing-types.d.ts" />
|
||||
|
||||
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;
|
||||
};
|
||||
|
417
src/shared/prerendered-app/Intro/blob-anim/index.ts
Normal file
@ -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();
|
||||
}
|
41
src/shared/prerendered-app/Intro/blob-anim/meta.ts
Normal file
@ -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],
|
||||
],
|
||||
];
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.8 MiB |
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
After Width: | Height: | Size: 3.3 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 3.6 KiB |
BIN
src/shared/prerendered-app/Intro/imgs/demos/icon-demo-logo.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
1
src/shared/prerendered-app/Intro/imgs/github-logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 29.34 28.61"><path fill="#838383" d="M14.67 0a14.67 14.67 0 00-4.64 28.59c.74.13 1-.32 1-.7l-.02-2.74c-4.08.89-4.94-1.73-4.94-1.73a3.88 3.88 0 00-1.63-2.14c-1.33-.91.1-.9.1-.9A3.08 3.08 0 016.8 21.9a3.12 3.12 0 004.27 1.22 3.12 3.12 0 01.93-1.96c-3.26-.37-6.68-1.63-6.68-7.25a5.68 5.68 0 011.5-3.94 5.27 5.27 0 01.15-3.9S8.2 5.7 11 7.58a13.9 13.9 0 017.34 0c2.8-1.9 4.03-1.5 4.03-1.5a5.27 5.27 0 01.15 3.9 5.67 5.67 0 011.5 3.93c0 5.63-3.42 6.87-6.7 7.24a3.5 3.5 0 011 2.71l-.01 4.03c0 .39.26.85 1 .7A14.67 14.67 0 0014.67 0z"/></svg>
|
After Width: | Height: | Size: 588 B |
1
src/shared/prerendered-app/Intro/imgs/logo-with-text.svg
Normal file
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
372
src/shared/prerendered-app/Intro/index.tsx
Normal file
@ -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<undefined | Blob> {
|
||||
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<Props, State> {
|
||||
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 (
|
||||
<div class={style.intro}>
|
||||
<input
|
||||
class={style.hide}
|
||||
ref={linkRef(this, 'fileInput')}
|
||||
type="file"
|
||||
onChange={this.onFileChange}
|
||||
/>
|
||||
<div class={style.main}>
|
||||
{!__PRERENDER__ && (
|
||||
<canvas
|
||||
ref={linkRef(this, 'blobCanvas')}
|
||||
class={style.blobCanvas}
|
||||
/>
|
||||
)}
|
||||
<h1 class={style.logoContainer}>
|
||||
<img
|
||||
class={style.logo}
|
||||
src={logoWithText}
|
||||
alt="Squoosh"
|
||||
width="539"
|
||||
height="162"
|
||||
/>
|
||||
</h1>
|
||||
<div class={style.loadImg}>
|
||||
{showBlobSVG && (
|
||||
<svg
|
||||
class={style.blobSvg}
|
||||
viewBox="-1.25 -1.25 2.5 2.5"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
{startBlobs.map((points) => (
|
||||
<path
|
||||
d={points
|
||||
.map((point, i) => {
|
||||
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('')}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
<div
|
||||
class={style.loadImgContent}
|
||||
style={{ visibility: __PRERENDER__ ? 'hidden' : '' }}
|
||||
>
|
||||
<button class={style.loadBtn} onClick={this.onOpenClick}>
|
||||
<svg viewBox="0 0 24 24" class={style.loadIcon}>
|
||||
<path d="M19 7v3h-2V7h-3V5h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5a2 2 0 00-2 2v12c0 1.1.9 2 2 2h12a2 2 0 002-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<span class={style.dropText}>Drop </span>OR{' '}
|
||||
{supportsClipboardAPI ? (
|
||||
<button class={style.pasteBtn} onClick={this.onPasteClick}>
|
||||
Paste
|
||||
</button>
|
||||
) : (
|
||||
'Paste'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class={style.demosContainer}>
|
||||
<svg viewBox="0 0 1920 140" class={style.topWave}>
|
||||
<path
|
||||
d="M1920 0l-107 28c-106 29-320 85-533 93-213 7-427-36-640-50s-427 0-533 7L0 85v171h1920z"
|
||||
class={style.subWave}
|
||||
/>
|
||||
<path
|
||||
d="M0 129l64-26c64-27 192-81 320-75 128 5 256 69 384 64 128-6 256-80 384-91s256 43 384 70c128 26 256 26 320 26h64v96H0z"
|
||||
class={style.mainWave}
|
||||
/>
|
||||
</svg>
|
||||
<div class={style.contentPadding}>
|
||||
<p class={style.demoTitle}>
|
||||
Or <strong>try one</strong> of these:
|
||||
</p>
|
||||
<ul class={style.demos}>
|
||||
{demos.map((demo, i) => (
|
||||
<li>
|
||||
<button
|
||||
class="unbutton"
|
||||
onClick={(event) => this.onDemoClick(i, event)}
|
||||
>
|
||||
<div>
|
||||
<div class={style.demoIconContainer}>
|
||||
<img
|
||||
class={style.demoIcon}
|
||||
src={demo.iconUrl}
|
||||
alt={demo.description}
|
||||
/>
|
||||
{fetchingDemoIndex === i && (
|
||||
<div class={style.demoLoader}>
|
||||
<loading-spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class={style.demoSize}>{demo.size}</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class={style.footer}>
|
||||
<svg viewBox="0 0 1920 79" class={style.topWave}>
|
||||
<path
|
||||
d="M0 59l64-11c64-11 192-34 320-43s256-5 384 4 256 23 384 34 256 21 384 14 256-30 320-41l64-11v94H0z"
|
||||
class={style.footerWave}
|
||||
/>
|
||||
</svg>
|
||||
<div class={style.contentPadding}>
|
||||
<footer class={style.footerItems}>
|
||||
<a
|
||||
class={style.footerLink}
|
||||
href="https://github.com/GoogleChromeLabs/squoosh/blob/dev/README.md#privacy"
|
||||
>
|
||||
Privacy
|
||||
</a>
|
||||
<a
|
||||
class={style.footerLinkWithLogo}
|
||||
href="https://github.com/GoogleChromeLabs/squoosh"
|
||||
>
|
||||
<img src={githubLogo} alt="" width="10" height="10" />
|
||||
Source on Github
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{beforeInstallEvent && (
|
||||
<button class={style.installBtn} onClick={this.onInstallClick}>
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -29,3 +29,12 @@ interface BeforeInstallPromptEvent extends Event {
|
||||
interface WindowEventMap {
|
||||
beforeinstallprompt: BeforeInstallPromptEvent;
|
||||
}
|
||||
|
||||
interface ClipboardItem {
|
||||
types: string[];
|
||||
getType(type: string): Promise<Blob>;
|
||||
}
|
||||
|
||||
interface Clipboard {
|
||||
read(): Promise<ClipboardItem[]>;
|
||||
}
|
243
src/shared/prerendered-app/Intro/style.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
19
src/shared/prerendered-app/colors.css
Normal file
@ -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);
|
||||
}
|
24
src/shared/prerendered-app/util.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -29,7 +29,7 @@ const toOutput: Output = {
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
background_color: '#fff',
|
||||
theme_color: '#f78f21',
|
||||
theme_color: '#ff3385',
|
||||
icons: [
|
||||
{
|
||||
src: iconLarge,
|
||||
|
2
src/static-build/missing-types.d.ts
vendored
@ -11,7 +11,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
/// <reference path="../../missing-types.d.ts" />
|
||||
/// <reference path="../shared/initial-app/Intro/missing-types.d.ts" />
|
||||
/// <reference path="../shared/prerendered-app/Intro/missing-types.d.ts" />
|
||||
|
||||
declare module 'client-bundle:*' {
|
||||
const url: string;
|
||||
|
@ -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;
|
||||
|
@ -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 {}
|
||||
|
||||
|