From be3ab8b6d175ba5d02b7a393080d3ae61d8b0968 Mon Sep 17 00:00:00 2001 From: Jake Archibald Date: Wed, 25 Nov 2020 13:39:48 +0000 Subject: [PATCH] Logo and animated blobs --- src/shared/initial-app/Intro/blob-anim.ts | 212 ++++++++++++++++++ .../initial-app/Intro/imgs/logo-with-text.svg | 1 + src/shared/initial-app/Intro/index.tsx | 31 ++- src/shared/initial-app/Intro/style.css | 25 ++- 4 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 src/shared/initial-app/Intro/blob-anim.ts create mode 100644 src/shared/initial-app/Intro/imgs/logo-with-text.svg diff --git a/src/shared/initial-app/Intro/blob-anim.ts b/src/shared/initial-app/Intro/blob-anim.ts new file mode 100644 index 00000000..b9b88f92 --- /dev/null +++ b/src/shared/initial-app/Intro/blob-anim.ts @@ -0,0 +1,212 @@ +import * as style from './style.css'; + +/** + * Control point x,y - point x,y - control point x,y + */ +type BlobPoint = [number, number, number, number, number, number]; + +const maxRandomDistance = 0.25; + +function randomisePoint(point: BlobPoint): BlobPoint { + const distance = Math.random() * maxRandomDistance; + 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; +} + +const randomDuration = () => Math.random() * 5000 + 4000; + +interface CircleBlobPointState { + basePoint: BlobPoint; + pos: number; + duration: number; + startPoint: BlobPoint; + endPoint: BlobPoint; +} + +class CircleBlob { + private animStates: CircleBlobPointState[] = []; + + constructor(points: number) { + const anglePerPoint = 360 / points; + const matrix = new DOMMatrix(); + const point = new DOMPoint(); + const controlDistance = (4 / 3) * Math.tan(Math.PI / (2 * points)); + + for (let i = 0; i < 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]; + this.animStates.push({ + basePoint, + pos: 0, + duration: randomDuration(), + startPoint: randomisePoint(basePoint), + endPoint: randomisePoint(basePoint), + }); + matrix.rotateSelf(0, 0, anglePerPoint); + } + } + + frame(timeDelta: number): BlobPoint[] { + return this.animStates.map((animState) => { + animState.pos += timeDelta / animState.duration; + if (animState.pos >= 1) { + animState.startPoint = animState.endPoint; + animState.pos = 0; + animState.duration = randomDuration(); + 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; + }); + } +} + +const rotationTime = 120000; + +class CentralBlobs { + private rotatePos: number = 0; + private blobs = Array.from({ length: 4 }, () => new CircleBlob(7)); + + draw( + ctx: CanvasRenderingContext2D, + timeDelta: number, + x: number, + y: number, + radius: number, + ) { + ctx.save(); + ctx.translate(x, y); + ctx.scale(radius, radius); + this.rotatePos = (this.rotatePos + timeDelta / rotationTime) % 1; + ctx.rotate(Math.PI * 2 * this.rotatePos); + + for (const blob of this.blobs) { + const points = blob.frame(timeDelta); + 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(); + } + + ctx.restore(); + } +} + +const deltaMultiplierStep = 0.01; + +export function startBlobAnim(canvas: HTMLCanvasElement) { + let lastTime: number; + const ctx = canvas.getContext('2d')!; + const centralBlobs = new CentralBlobs(); + const loadImgEl = document.querySelector('.' + style.loadImg)!; + let deltaMultiplier = 1; + let hasFocus = true; + let animating = true; + + const focusListener = () => { + hasFocus = true; + if (!animating) startAnim(); + }; + const blurListener = () => { + hasFocus = false; + }; + + addEventListener('focus', focusListener); + addEventListener('blur', blurListener); + + function destruct() { + removeEventListener('focus', focusListener); + removeEventListener('blur', blurListener); + } + + 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; + + const canvasBounds = canvas.getBoundingClientRect(); + canvas.width = canvasBounds.width * devicePixelRatio; + canvas.height = canvasBounds.height * devicePixelRatio; + const loadImgBounds = loadImgEl.getBoundingClientRect(); + + ctx.fillStyle = 'rgba(255, 0, 102, 0.3)'; + ctx.scale(devicePixelRatio, devicePixelRatio); + + centralBlobs.draw( + ctx, + delta, + loadImgBounds.left - canvasBounds.left + loadImgBounds.width / 2, + loadImgBounds.top - canvasBounds.top + loadImgBounds.height / 2, + (loadImgBounds.width / 2) * (1 - maxRandomDistance), + ); + + requestAnimationFrame(frame); + } + + function startAnim() { + animating = true; + requestAnimationFrame((time: number) => { + lastTime = time; + frame(time); + }); + } + + startAnim(); +} diff --git a/src/shared/initial-app/Intro/imgs/logo-with-text.svg b/src/shared/initial-app/Intro/imgs/logo-with-text.svg new file mode 100644 index 00000000..8ea594f2 --- /dev/null +++ b/src/shared/initial-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/index.tsx b/src/shared/initial-app/Intro/index.tsx index cc6b3362..8eef2357 100644 --- a/src/shared/initial-app/Intro/index.tsx +++ b/src/shared/initial-app/Intro/index.tsx @@ -10,6 +10,7 @@ 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/initial-app/custom-els/snack-bar'; import 'shared/initial-app/custom-els/snack-bar'; @@ -41,6 +42,7 @@ const demos = [ }, ]; +const blobAnimImport = import('./blob-anim'); const installButtonSource = 'introInstallButton-Purple'; const supportsClipboardAPI = !__PRERENDER__ && navigator.clipboard && navigator.clipboard.read; @@ -66,6 +68,7 @@ interface State { export default class Intro extends Component { state: State = {}; private fileInput?: HTMLInputElement; + private blobCanvas?: HTMLCanvasElement; private installingViaButton = false; componentDidMount() { @@ -77,6 +80,8 @@ export default class Intro extends Component { // Listen for the appinstalled event, indicating Squoosh has been installed. window.addEventListener('appinstalled', this.onAppInstalled); + + blobAnimImport.then((module) => module.startBlobAnim(this.blobCanvas!)); } componentWillUnmount() { @@ -95,7 +100,7 @@ export default class Intro extends Component { this.props.onFile!(file); }; - private onButtonClick = () => { + private onOpenClick = () => { this.fileInput!.click(); }; @@ -179,19 +184,17 @@ export default class Intro extends Component { try { clipboardItems = await navigator.clipboard.read(); } catch (err) { - this.props.showSnack!(`Cannot access clipboard`); + this.props.showSnack!(`No permission to access clipboard`); return; } const blob = await getImageClipboardItem(clipboardItems); if (!blob) { - this.props.showSnack!(`No image found`); + this.props.showSnack!(`No image found in the clipboard`); return; } - console.log(blob); - this.props.onFile!(new File([blob], 'image.unknown')); }; @@ -208,13 +211,27 @@ export default class Intro extends Component { )}
-
Logo Placeholder
+ {!__PRERENDER__ && ( + + )} +

+ Squoosh +

-