mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-13 09:17:20 +00:00
Unfortunately, due to a few people leaving the team, and staffing issues resulting from the current economic climate (ugh), I'm deprecating the CLI and libsquoosh parts of Squoosh. The web app will continue to be supported and improved. I know that sucks, but there simply isn't the time & people to work on this. If anyone from the community wants to fork it, you have my blessing.
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
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 smallSectionAsset from 'url:./imgs/info-content/small.svg';
|
|
import simpleSectionAsset from 'url:./imgs/info-content/simple.svg';
|
|
import secureSectionAsset from 'url:./imgs/info-content/secure.svg';
|
|
import logoIcon from 'url:./imgs/demos/icon-demo-logo.png';
|
|
import logoWithText from 'data-url-text:./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';
|
|
import SlideOnScroll from './SlideOnScroll';
|
|
|
|
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,
|
|
},
|
|
] as const;
|
|
|
|
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.bottomWave}>
|
|
<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.infoWave}
|
|
/>
|
|
</svg>
|
|
</div>
|
|
|
|
<section class={style.info}>
|
|
<div class={style.infoContainer}>
|
|
<SlideOnScroll>
|
|
<div class={style.infoContent}>
|
|
<div class={style.infoTextWrapper}>
|
|
<h2 class={style.infoTitle}>Small</h2>
|
|
<p class={style.infoCaption}>
|
|
Smaller images mean faster load times. Squoosh can reduce
|
|
file size and maintain high quality.
|
|
</p>
|
|
</div>
|
|
<div class={style.infoImgWrapper}>
|
|
<img
|
|
class={style.infoImg}
|
|
src={smallSectionAsset}
|
|
alt="silhouette of a large 1.4 megabyte image shrunk into a smaller 80 kilobyte image"
|
|
width="536"
|
|
height="522"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</SlideOnScroll>
|
|
</div>
|
|
</section>
|
|
|
|
<section class={style.info}>
|
|
<div class={style.infoContainer}>
|
|
<SlideOnScroll>
|
|
<div class={style.infoContent}>
|
|
<div class={style.infoTextWrapper}>
|
|
<h2 class={style.infoTitle}>Simple</h2>
|
|
<p class={style.infoCaption}>
|
|
Open your image, inspect the differences, then save
|
|
instantly. Feeling adventurous? Adjust the settings for even
|
|
smaller files.
|
|
</p>
|
|
</div>
|
|
<div class={style.infoImgWrapper}>
|
|
<img
|
|
class={style.infoImg}
|
|
src={simpleSectionAsset}
|
|
alt="grid of multiple shrunk images displaying various options"
|
|
width="538"
|
|
height="384"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</SlideOnScroll>
|
|
</div>
|
|
</section>
|
|
|
|
<section class={style.info}>
|
|
<div class={style.infoContainer}>
|
|
<SlideOnScroll>
|
|
<div class={style.infoContent}>
|
|
<div class={style.infoTextWrapper}>
|
|
<h2 class={style.infoTitle}>Secure</h2>
|
|
<p class={style.infoCaption}>
|
|
Worried about privacy? Images never leave your device since
|
|
Squoosh does all the work locally.
|
|
</p>
|
|
</div>
|
|
<div class={style.infoImgWrapper}>
|
|
<img
|
|
class={style.infoImg}
|
|
src={secureSectionAsset}
|
|
alt="silhouette of a cloud with a 'no' symbol on top"
|
|
width="498"
|
|
height="333"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</SlideOnScroll>
|
|
</div>
|
|
</section>
|
|
|
|
<footer class={style.footer}>
|
|
<div class={style.footerContainer}>
|
|
<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.footerPadding}>
|
|
<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>
|
|
</footer>
|
|
{beforeInstallEvent && (
|
|
<button class={style.installBtn} onClick={this.onInstallClick}>
|
|
Install
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
}
|