diff --git a/lib/css-plugin.js b/lib/css-plugin.js index 8e2f9082..952da5fc 100644 --- a/lib/css-plugin.js +++ b/lib/css-plugin.js @@ -101,7 +101,7 @@ export default function (resolveFileUrl) { hashToId = new Map(); pathToResult = new Map(); - const cssPaths = await globP('src/static-build/**/*.css', { + const cssPaths = await globP('src/**/*.css', { nodir: true, absolute: true, }); @@ -126,11 +126,11 @@ export default function (resolveFileUrl) { const cssClassExports = Object.entries(moduleJSON).map( ([key, val]) => - `export const $${camelCase(key)} = ${JSON.stringify(val)};`, + `export const ${camelCase(key)} = ${JSON.stringify(val)};`, ); const defs = Object.keys(moduleJSON) - .map((key) => `export const $${camelCase(key)}: string;`) + .map((key) => `export const ${camelCase(key)}: string;`) .join('\n'); const defPath = path + '.d.ts'; diff --git a/missing-types.d.ts b/missing-types.d.ts index 27f3ea7d..32d82327 100644 --- a/missing-types.d.ts +++ b/missing-types.d.ts @@ -21,3 +21,5 @@ declare module 'omt:*' { const value: string; export default value; } + +declare const __PRODUCTION__: boolean; diff --git a/package-lock.json b/package-lock.json index 6b0a5589..c76dc9aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,9 +109,9 @@ } }, "@rollup/plugin-commonjs": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-15.0.0.tgz", - "integrity": "sha512-8uAdikHqVyrT32w1zB9VhW6uGwGjhKgnDNP4pQJsjdnyF4FgCj6/bmv24c7v2CuKhq32CcyCwRzMPEElaKkn0w==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-15.1.0.tgz", + "integrity": "sha512-xCQqz4z/o0h2syQ7d9LskIMvBSH4PX5PjYdpSSvgS+pQik3WahkQVNWg3D8XJeYjZoVWnIUQYDghuEMRGrmQYQ==", "dev": true, "requires": { "@rollup/pluginutils": "^3.1.0", @@ -145,6 +145,16 @@ "resolve": "^1.17.0" } }, + "@rollup/plugin-replace": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.3.3.tgz", + "integrity": "sha512-XPmVXZ7IlaoWaJLkSCDaa0Y6uVo5XQYHhiMFzOd5qSv5rE+t/UJToPIOE56flKIxBFQI27ONsxb7dqHnwSsjKQ==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.8", + "magic-string": "^0.25.5" + } + }, "@rollup/pluginutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", @@ -195,9 +205,9 @@ "dev": true }, "@types/node": { - "version": "14.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.10.1.tgz", - "integrity": "sha512-aYNbO+FZ/3KGeQCEkNhHFRIzBOUgc7QvcVNKXbfnhDkSfwUv91JsQQa10rDgKSTSLkXZ1UIyPe4FJJNVgw1xWQ==", + "version": "14.11.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.2.tgz", + "integrity": "sha512-jiE3QIxJ8JLNcb1Ps6rDbysDhN4xa8DJJvuC9prr6w+1tIh+QAbYyNF3tyiZNLDBIuBCf4KEcV2UvQm/V60xfA==", "dev": true }, "@types/parse-json": { @@ -1157,12 +1167,12 @@ "dev": true }, "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", + "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==", "dev": true, "requires": { - "ms": "^2.1.1" + "ms": "2.1.2" } }, "dedent": { @@ -1457,6 +1467,12 @@ "escape-string-regexp": "^1.0.5" } }, + "file-drop-element": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-drop-element/-/file-drop-element-1.0.0.tgz", + "integrity": "sha512-4T+hoNZR7hMumVcCUbmg2XtjGph15thvsT40+Xu8snMBpnDsRFhBnZ6Nhxbnwot451gg8EfJzQRS+Wmr4j7Ytw==", + "dev": true + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -1923,9 +1939,9 @@ "dev": true }, "lint-staged": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.3.0.tgz", - "integrity": "sha512-an3VgjHqmJk0TORB/sdQl0CTkRg4E5ybYCXTTCSJ5h9jFwZbcgKIx5oVma5e7wp/uKt17s1QYFmYqT9MGVosGw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.4.0.tgz", + "integrity": "sha512-uaiX4U5yERUSiIEQc329vhCTDDwUcSvKdRLsNomkYLRzijk3v8V9GWm2Nz0RMVB87VcuzLvtgy6OsjoH++QHIg==", "dev": true, "requires": { "chalk": "^4.1.0", @@ -2399,9 +2415,9 @@ } }, "postcss": { - "version": "7.0.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz", - "integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==", + "version": "7.0.34", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.34.tgz", + "integrity": "sha512-H/7V2VeNScX9KE83GDrDZNiGT1m2H+UTnlinIzhjlLX9hfMUn1mHNnGeX81a1c8JSBdBvqk7c2ZOG6ZPn5itGw==", "dev": true, "requires": { "chalk": "^2.4.2", @@ -3063,9 +3079,9 @@ "dev": true }, "preact": { - "version": "10.4.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.4.8.tgz", - "integrity": "sha512-uVLeEAyRsCkUEFhVHlOu17OxcrwC7+hTGZ08kBoLBiGHiZooUZuibQnphgMKftw/rqYntNMyhVCPqQhcyAGHag==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.5.0.tgz", + "integrity": "sha512-CuhSq2uq1lUy9442j9Jlucapt8+9SFyNl1+evzbMb8dTF4GCPrc1XMvf9Hai7XbeXG/wIxR0TVhhEFKJ3DkY6Q==", "dev": true }, "preact-render-to-string": { @@ -3078,9 +3094,9 @@ } }, "prettier": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.1.tgz", - "integrity": "sha512-9bY+5ZWCfqj3ghYBLxApy2zf6m+NJo5GzmLTpr9FsApsfjriNnS2dahWReHMi7qNPhhHl9SYHJs2cHZLgexNIw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.2.tgz", + "integrity": "sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg==", "dev": true }, "pretty-format": { @@ -3225,9 +3241,9 @@ } }, "rollup": { - "version": "2.26.11", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.26.11.tgz", - "integrity": "sha512-xyfxxhsE6hW57xhfL1I+ixH8l2bdoIMaAecdQiWF3N7IgJEMu99JG+daBiSZQjnBpzFxa0/xZm+3pbCdAQehHw==", + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.28.1.tgz", + "integrity": "sha512-DOtVoqOZt3+FjPJWLU8hDIvBjUylc9s6IZvy76XklxzcLvAQLtVAG/bbhsMhcWnYxC0TKKcf1QQ/tg29zeID0Q==", "dev": true, "requires": { "fsevents": "~2.1.2" @@ -3812,9 +3828,9 @@ "dev": true }, "typescript": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.2.tgz", - "integrity": "sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.3.tgz", + "integrity": "sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg==", "dev": true }, "uniq": { diff --git a/package.json b/package.json index 335e6012..32a51816 100644 --- a/package.json +++ b/package.json @@ -10,29 +10,31 @@ "serve": "serve --config server.json .tmp/build/static" }, "devDependencies": { - "@rollup/plugin-commonjs": "^15.0.0", + "@rollup/plugin-commonjs": "^15.1.0", "@rollup/plugin-node-resolve": "^9.0.0", + "@rollup/plugin-replace": "^2.3.3", "@surma/rollup-plugin-off-main-thread": "^1.4.1", - "@types/node": "^14.10.1", + "@types/node": "^14.11.2", "comlink": "^4.3.0", "cssnano": "^4.1.10", "del": "^5.1.0", + "file-drop-element": "^1.0.0", "husky": "^4.3.0", - "lint-staged": "^10.3.0", + "lint-staged": "^10.4.0", "lodash.camelcase": "^4.3.0", - "postcss": "^7.0.32", + "postcss": "^7.0.34", "postcss-import": "^12.0.1", "postcss-modules": "^3.2.2", "postcss-nested": "^4.2.3", "postcss-simple-vars": "^5.0.2", "postcss-url": "^8.0.0", - "preact": "^10.4.8", + "preact": "^10.5.0", "preact-render-to-string": "^5.1.10", - "prettier": "^2.1.1", - "rollup": "^2.26.11", + "prettier": "^2.1.2", + "rollup": "^2.28.1", "rollup-plugin-terser": "^7.0.2", "serve": "^11.3.2", - "typescript": "^4.0.2" + "typescript": "^4.0.3" }, "husky": { "hooks": { diff --git a/rollup.config.js b/rollup.config.js index 3b916e39..c76e2e5d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -17,6 +17,7 @@ import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import { terser } from 'rollup-plugin-terser'; import OMT from '@surma/rollup-plugin-off-main-thread'; +import replace from '@rollup/plugin-replace'; import simpleTS from './lib/simple-ts'; import clientBundlePlugin from './lib/client-bundle-plugin'; @@ -46,6 +47,8 @@ export default async function ({ watch }) { ); await del('.tmp/build'); + const isProduction = !watch; + const tsPluginInstance = simpleTS('.', { watch, }); @@ -84,7 +87,8 @@ export default async function ({ watch }) { ...commonPlugins(), commonjs(), resolve(), - terser({ module: true }), + replace({ __PRERENDER__: false, __PRODUCTION__: isProduction }), + //terser({ module: true }), ], }, { @@ -99,6 +103,7 @@ export default async function ({ watch }) { emitFiles({ include: '**/*', root: path.join(__dirname, 'src', 'copy') }), nodeExternalPlugin(), imageWorkerPlugin(), + replace({ __PRERENDER__: true, __PRODUCTION__: isProduction }), runScript(dir + '/index.js'), ], }; diff --git a/src/client/index.tsx b/src/client/index.tsx deleted file mode 100644 index 76bb60a0..00000000 --- a/src/client/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Copyright 2020 Google Inc. All Rights Reserved. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { wrap } from 'comlink'; -import workerURL from 'omt:features/worker'; -import imgURL from 'url:./tmp.png'; - -import type { ProcessorWorkerApi } from 'features/worker'; -const worker = new Worker(workerURL); -const api = wrap(worker); - -async function demo() { - const img = document.createElement('img'); - img.src = imgURL; - await img.decode(); - // Make canvas same size as image - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d')!; - ctx.drawImage(img, 0, 0); - const data = ctx.getImageData(0, 0, img.width, img.height); - const result = await api.rotate(data, { - rotate: 180, - }); - - { - /*const resultUrl = URL.createObjectURL(new Blob([result])); - const img = new Image(); - img.src = resultUrl; - document.body.append(img);*/ - - const canvas = document.createElement('canvas'); - canvas.width = result.width; - canvas.height = result.height; - const ctx = canvas.getContext('2d')!; - ctx.putImageData(result, 0, 0); - document.body.append(canvas); - } -} - -demo(); diff --git a/src/client/initial-app/App/index.tsx b/src/client/initial-app/App/index.tsx new file mode 100644 index 00000000..6afdc147 --- /dev/null +++ b/src/client/initial-app/App/index.tsx @@ -0,0 +1,137 @@ +import type { FileDropEvent } from 'file-drop-element'; +import type SnackBarElement from 'client/initial-app/custom-els/snack-bar'; +import type { SnackOptions } from 'client/initial-app/custom-els/snack-bar'; + +import { h, Component } from 'preact'; + +import { linkRef } from 'client/initial-app/util'; +import * as style from './style.css'; +import 'file-drop-element'; +import 'client/initial-app/custom-els/snack-bar'; +//import Intro from '../intro'; +import 'client/initial-app/custom-els/loading-spinner'; + +const ROUTE_EDITOR = '/editor'; + +//const compressPromise = import('../compress'); +//const swBridgePromise = import('../../lib/sw-bridge'); + +function back() { + window.history.back(); +} + +interface Props {} + +interface State { + awaitingShareTarget: boolean; + file?: File; + isEditorOpen: Boolean; + Compress?: undefined; // typeof import('../compress').default; +} + +export default class App extends Component { + state: State = { + awaitingShareTarget: new URL(location.href).searchParams.has( + 'share-target', + ), + isEditorOpen: false, + file: undefined, + Compress: undefined, + }; + + snackbar?: SnackBarElement; + + constructor() { + super(); + + /*compressPromise + .then((module) => { + this.setState({ Compress: module.default }); + }) + .catch(() => { + this.showSnack('Failed to load app'); + }); + + swBridgePromise.then(async ({ offliner, getSharedImage }) => { + offliner(this.showSnack); + if (!this.state.awaitingShareTarget) return; + const file = await getSharedImage(); + // Remove the ?share-target from the URL + history.replaceState('', '', '/'); + this.openEditor(); + this.setState({ file, awaitingShareTarget: false }); + });*/ + + // Since iOS 10, Apple tries to prevent disabling pinch-zoom. This is great in theory, but + // really breaks things on Squoosh, as you can easily end up zooming the UI when you mean to + // zoom the image. Once you've done this, it's really difficult to undo. Anyway, this seems to + // prevent it. + document.body.addEventListener('gesturestart', (event) => { + event.preventDefault(); + }); + + window.addEventListener('popstate', this.onPopState); + } + + private onFileDrop = ({ files }: FileDropEvent) => { + if (!files || files.length === 0) return; + const file = files[0]; + this.openEditor(); + this.setState({ file }); + }; + + private onIntroPickFile = (file: File) => { + this.openEditor(); + this.setState({ file }); + }; + + private showSnack = ( + message: string, + options: SnackOptions = {}, + ): Promise => { + if (!this.snackbar) throw Error('Snackbar missing'); + return this.snackbar.showSnackbar(message, options); + }; + + private onPopState = () => { + this.setState({ isEditorOpen: location.pathname === ROUTE_EDITOR }); + }; + + private openEditor = () => { + if (this.state.isEditorOpen) return; + // Change path, but preserve query string. + const editorURL = new URL(location.href); + editorURL.pathname = ROUTE_EDITOR; + history.pushState(null, '', editorURL.href); + this.setState({ isEditorOpen: true }); + }; + + render( + {}: Props, + { file, isEditorOpen, Compress, awaitingShareTarget }: State, + ) { + const showSpinner = awaitingShareTarget || (isEditorOpen && !Compress); + + return ( +
+ + {showSpinner ? ( + + ) : isEditorOpen ? ( + Compress && + // + 'TODO: uncomment above' + ) : ( + // + 'TODO: show intro here' + )} + + +
+ ); + } +} diff --git a/src/client/initial-app/App/style.css b/src/client/initial-app/App/style.css new file mode 100644 index 00000000..d23b5483 --- /dev/null +++ b/src/client/initial-app/App/style.css @@ -0,0 +1,68 @@ +.app { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: hidden; + contain: strict; +} + +.drop { + overflow: hidden; + touch-action: none; + height: 100%; + width: 100%; + + &:global { + &::after { + content: ''; + position: absolute; + display: block; + left: 10px; + top: 10px; + right: 10px; + bottom: 10px; + border: 2px dashed #fff; + background-color: rgba(88, 116, 88, 0.2); + border-color: rgba(65, 129, 65, 0.5); + border-radius: 10px; + opacity: 0; + transform: scale(0.95); + transition: all 200ms ease-in; + transition-property: transform, opacity; + pointer-events: none; + } + + &.drop-valid::after { + opacity: 1; + transform: scale(1); + transition-timing-function: ease-out; + } + } +} + +.option-pair { + display: flex; + justify-content: flex-end; + width: 100%; + height: 100%; + + &.horizontal { + justify-content: space-between; + align-items: flex-end; + } + + &.vertical { + flex-direction: column; + } +} + +.app-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + --size: 225px; + --stroke-width: 26px; +} diff --git a/src/client/initial-app/custom-els/loading-spinner/index.ts b/src/client/initial-app/custom-els/loading-spinner/index.ts new file mode 100644 index 00000000..e1af112d --- /dev/null +++ b/src/client/initial-app/custom-els/loading-spinner/index.ts @@ -0,0 +1,62 @@ +import * as styles from './styles.css'; + +/** + * A simple spinner. This custom element has no JS API. Just put it in the document, and it'll + * spin. You can configure the following using CSS custom properties: + * + * --size: Size of the spinner element (it's always square). Default: 28px. + * --color: Color of the spinner. Default: #4285f4. + * --stroke-width: Width of the stroke of the spinner. Default: 3px. + * --delay: Once the spinner enters the DOM, how long until it shows. This prevents the spinner + * appearing on the screen for short operations. Default: 300ms. + */ +export default class LoadingSpinner extends HTMLElement { + private _delayTimeout: number = 0; + + constructor() { + super(); + + // Ideally we'd use shadow DOM here, but we're targeting browsers without shadow DOM support. + // You can't set attributes/content in a custom element constructor, so I'm waiting a microtask. + Promise.resolve().then(() => { + this.style.display = 'none'; + // prettier-ignore + this.innerHTML = '' + + `
` + + `
` + + `
` + + `
` + + '
' + + `
` + + `
` + + '
' + + `
` + + `
` + + '
' + + '
' + + '
'; + }); + } + + disconnectedCallback() { + this.style.display = 'none'; + clearTimeout(this._delayTimeout); + } + + connectedCallback() { + const delayStr = getComputedStyle(this).getPropertyValue('--delay').trim(); + let delayNum = parseFloat(delayStr); + + // If seconds… + if (/\ds$/.test(delayStr)) { + // Convert to ms. + delayNum *= 1000; + } + + this._delayTimeout = self.setTimeout(() => { + this.style.display = ''; + }, delayNum); + } +} + +customElements.define('loading-spinner', LoadingSpinner); diff --git a/src/client/initial-app/custom-els/loading-spinner/missing-types.d.ts b/src/client/initial-app/custom-els/loading-spinner/missing-types.d.ts new file mode 100644 index 00000000..35f42823 --- /dev/null +++ b/src/client/initial-app/custom-els/loading-spinner/missing-types.d.ts @@ -0,0 +1,13 @@ +interface LoadingSpinner extends preact.JSX.HTMLAttributes {} + +declare module 'preact' { + namespace createElement.JSX { + interface IntrinsicElements { + 'loading-spinner': LoadingSpinner; + } + } +} + +// Thing break unless this file is a module. +// Don't ask me why. I don't know. +export {}; diff --git a/src/client/initial-app/custom-els/loading-spinner/styles.css b/src/client/initial-app/custom-els/loading-spinner/styles.css new file mode 100644 index 00000000..45017087 --- /dev/null +++ b/src/client/initial-app/custom-els/loading-spinner/styles.css @@ -0,0 +1,158 @@ +@keyframes spinner-left-spin { + from { + transform: rotate(130deg); + } + 50% { + transform: rotate(-5deg); + } + to { + transform: rotate(130deg); + } +} + +@keyframes spinner-right-spin { + from { + transform: rotate(-130deg); + } + 50% { + transform: rotate(5deg); + } + to { + transform: rotate(-130deg); + } +} + +@keyframes spinner-fade-out { + to { + opacity: 0; + } +} + +@keyframes spinner-container-rotate { + to { + transform: rotate(360deg); + } +} + +@keyframes spinner-fill-unfill-rotate { + 12.5% { + transform: rotate(135deg); + } /* 0.5 * ARCSIZE */ + 25% { + transform: rotate(270deg); + } /* 1 * ARCSIZE */ + 37.5% { + transform: rotate(405deg); + } /* 1.5 * ARCSIZE */ + 50% { + transform: rotate(540deg); + } /* 2 * ARCSIZE */ + 62.5% { + transform: rotate(675deg); + } /* 2.5 * ARCSIZE */ + 75% { + transform: rotate(810deg); + } /* 3 * ARCSIZE */ + 87.5% { + transform: rotate(945deg); + } /* 3.5 * ARCSIZE */ + to { + transform: rotate(1080deg); + } /* 4 * ARCSIZE */ +} + +loading-spinner { + --size: 28px; + --color: #4285f4; + --stroke-width: 3px; + --delay: 300ms; + + pointer-events: none; + display: inline-block; + position: relative; + width: var(--size); + height: var(--size); + border-color: var(--color); +} + +loading-spinner .spinner-circle { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + box-sizing: border-box; + height: 100%; + width: 200%; + border-width: var(--stroke-width); + border-style: solid; + border-color: inherit; + border-bottom-color: transparent !important; + border-radius: 50%; +} + +/* + Patch the gap that appear between the two adjacent div.circle-clipper while the + spinner is rotating (appears on Chrome 38, Safari 7.1, and IE 11). +*/ +loading-spinner .spinner-gap-patch { + position: absolute; + box-sizing: border-box; + top: 0; + left: 45%; + width: 10%; + height: 100%; + overflow: hidden; + border-color: inherit; +} + +loading-spinner .spinner-gap-patch .spinner-circle { + width: 1000%; + left: -450%; +} + +loading-spinner .spinner-circle-clipper { + display: inline-block; + position: relative; + width: 50%; + height: 100%; + overflow: hidden; + border-color: inherit; +} + +loading-spinner .spinner-left .spinner-circle { + border-right-color: transparent !important; + transform: rotate(129deg); + animation: spinner-left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both; +} + +loading-spinner .spinner-right .spinner-circle { + left: -100%; + border-left-color: transparent !important; + transform: rotate(-129deg); + animation: spinner-right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite + both; +} + +loading-spinner.spinner-fadeout { + animation: spinner-fade-out 400ms cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +loading-spinner .spinner-container { + width: 100%; + height: 100%; + border-color: inherit; + + /* duration: 360 * ARCTIME / (ARCSTARTROT + (360-ARCSIZE)) */ + animation: spinner-container-rotate 1568ms linear infinite; +} + +loading-spinner .spinner-layer { + position: absolute; + width: 100%; + height: 100%; + border-color: inherit; + /* durations: 4 * ARCTIME */ + animation: spinner-fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) + infinite both; +} diff --git a/src/client/initial-app/custom-els/missing-types.d.ts b/src/client/initial-app/custom-els/missing-types.d.ts new file mode 100644 index 00000000..64dcc636 --- /dev/null +++ b/src/client/initial-app/custom-els/missing-types.d.ts @@ -0,0 +1,16 @@ +import type { FileDropElement, FileDropEvent } from 'file-drop-element'; + +interface FileDropAttributes extends preact.JSX.HTMLAttributes { + accept?: string; + onfiledrop?: ((this: FileDropElement, ev: FileDropEvent) => any) | null; +} + +declare module 'preact' { + namespace createElement.JSX { + interface IntrinsicElements { + 'file-drop': FileDropAttributes; + } + } +} + +export {}; diff --git a/src/client/initial-app/custom-els/snack-bar/index.ts b/src/client/initial-app/custom-els/snack-bar/index.ts new file mode 100644 index 00000000..0992fbcc --- /dev/null +++ b/src/client/initial-app/custom-els/snack-bar/index.ts @@ -0,0 +1,95 @@ +import * as style from './styles.css'; + +export interface SnackOptions { + timeout?: number; + actions?: string[]; +} + +function createSnack( + message: string, + options: SnackOptions, +): [Element, Promise] { + const { timeout = 0, actions = ['dismiss'] } = options; + + const el = document.createElement('div'); + el.className = style.snackbar; + el.setAttribute('aria-live', 'assertive'); + el.setAttribute('aria-atomic', 'true'); + el.setAttribute('aria-hidden', 'false'); + + const text = document.createElement('div'); + text.className = style.text; + text.textContent = message; + el.appendChild(text); + + const result = new Promise((resolve) => { + let timeoutId: number; + + // Add action buttons + for (const action of actions) { + const button = document.createElement('button'); + button.className = style.button; + button.textContent = action; + button.addEventListener('click', () => { + clearTimeout(timeoutId); + resolve(action); + }); + el.appendChild(button); + } + + // Add timeout + if (timeout) { + timeoutId = self.setTimeout(() => resolve(''), timeout); + } + }); + + return [el, result]; +} + +export default class SnackBarElement extends HTMLElement { + private _snackbars: [ + string, + SnackOptions, + (action: Promise) => void, + ][] = []; + private _processingQueue = false; + + /** + * Show a snackbar. Returns a promise for the name of the action clicked, or an empty string if no + * action is clicked. + */ + showSnackbar(message: string, options: SnackOptions = {}): Promise { + return new Promise((resolve) => { + this._snackbars.push([message, options, resolve]); + if (!this._processingQueue) this._processQueue(); + }); + } + + private async _processQueue() { + this._processingQueue = true; + + while (this._snackbars[0]) { + const [message, options, resolver] = this._snackbars[0]; + const [el, result] = createSnack(message, options); + // Pass the result back to the original showSnackbar call. + resolver(result); + this.appendChild(el); + + // Wait for the user to click an action, or for the snack to timeout. + await result; + + // Transition the snack away. + el.setAttribute('aria-hidden', 'true'); + await new Promise((resolve) => { + el.addEventListener('animationend', () => resolve()); + }); + el.remove(); + + this._snackbars.shift(); + } + + this._processingQueue = false; + } +} + +customElements.define('snack-bar', SnackBarElement); diff --git a/src/client/initial-app/custom-els/snack-bar/missing-types.d.ts b/src/client/initial-app/custom-els/snack-bar/missing-types.d.ts new file mode 100644 index 00000000..16c86a94 --- /dev/null +++ b/src/client/initial-app/custom-els/snack-bar/missing-types.d.ts @@ -0,0 +1,15 @@ +import type { SnackOptions } from '.'; + +interface SnackBarAttributes extends preact.JSX.HTMLAttributes { + showSnackbar?: (options: SnackOptions) => Promise; +} + +declare module 'preact' { + namespace createElement.JSX { + interface IntrinsicElements { + 'snack-bar': SnackBarAttributes; + } + } +} + +export {}; diff --git a/src/client/initial-app/custom-els/snack-bar/styles.css b/src/client/initial-app/custom-els/snack-bar/styles.css new file mode 100644 index 00000000..f20552cb --- /dev/null +++ b/src/client/initial-app/custom-els/snack-bar/styles.css @@ -0,0 +1,105 @@ +snack-bar { + display: block; + position: fixed; + left: 0; + bottom: 0; + width: 100%; + height: 0; + overflow: visible; +} + +.snackbar { + position: fixed; + display: flex; + box-sizing: border-box; + left: 50%; + bottom: 24px; + width: 344px; + margin-left: -172px; + background: #2a2a2a; + border-radius: 2px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); + transform-origin: center; + color: #eee; + z-index: 100; + cursor: default; + will-change: transform; + animation: snackbar-show 300ms ease forwards 1; +} +.snackbar[aria-hidden='true'] { + animation: snackbar-hide 300ms ease forwards 1; +} +@keyframes snackbar-show { + from { + opacity: 0; + transform: scale(0.5); + } +} +@keyframes snackbar-hide { + to { + opacity: 0; + transform: translateY(100%); + } +} + +@media (max-width: 400px) { + .snackbar { + width: 100%; + bottom: 0; + left: 0; + margin-left: 0; + border-radius: 0; + } +} + +.text { + flex: 1 1 auto; + padding: 16px; + font-size: 100%; +} + +.button { + position: relative; + flex: 0 1 auto; + padding: 8px; + height: 36px; + margin: auto 8px auto -8px; + min-width: 5em; + background: none; + border: none; + border-radius: 3px; + color: lightgreen; + font-weight: inherit; + letter-spacing: 0.05em; + font-size: 100%; + text-transform: uppercase; + text-align: center; + cursor: pointer; + overflow: hidden; + transition: background-color 200ms ease; + outline: none; +} +.button:hover { + background-color: rgba(0, 0, 0, 0.15); +} +.button:focus:before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 120%; + height: 0; + padding: 0 0 120%; + margin: -60% 0 0 -60%; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + transform-origin: center; + will-change: transform; + animation: focus-ring 300ms ease-out forwards 1; + pointer-events: none; +} +@keyframes focus-ring { + from { + transform: scale(0.01); + } +} diff --git a/src/client/initial-app/index.tsx b/src/client/initial-app/index.tsx new file mode 100644 index 00000000..c81affc4 --- /dev/null +++ b/src/client/initial-app/index.tsx @@ -0,0 +1,44 @@ +/** + * Copyright 2020 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { h, render } from 'preact'; +import App from './App'; + +const root = document.getElementById('app') as HTMLElement; + +async function main() { + if (!__PRODUCTION__) await import('preact/debug'); + render(, root); +} + +main(); + +// Analytics +{ + // Determine the current display mode. + const displayMode = + navigator.standalone || + window.matchMedia('(display-mode: standalone)').matches + ? 'standalone' + : 'browser'; + + // Setup analytics + window.ga = window.ga || ((...args) => (ga.q = ga.q || []).push(args)); + ga('create', 'UA-128752250-1', 'auto'); + ga('set', 'transport', 'beacon'); + ga('set', 'dimension1', displayMode); + ga('send', 'pageview', '/index.html', { title: 'Squoosh' }); + // Load the GA script + const script = document.createElement('script'); + script.src = 'https://www.google-analytics.com/analytics.js'; + document.head.appendChild(script); +} diff --git a/src/client/initial-app/util.ts b/src/client/initial-app/util.ts new file mode 100644 index 00000000..d120214b --- /dev/null +++ b/src/client/initial-app/util.ts @@ -0,0 +1,15 @@ +/** Creates a function ref that assigns its value to a given property of an object. + * @example + * // element is stored as `this.foo` when rendered. + *
+ */ +export function linkRef(obj: any, name: string) { + const refName = `$$ref_${name}`; + let ref = obj[refName]; + if (!ref) { + ref = obj[refName] = (c: T) => { + obj[name] = c; + }; + } + return ref; +} diff --git a/src/client/missing-types.d.ts b/src/client/missing-types.d.ts index 27b1d41c..530bd6c0 100644 --- a/src/client/missing-types.d.ts +++ b/src/client/missing-types.d.ts @@ -11,3 +11,14 @@ * limitations under the License. */ /// + +declare var ga: { + (...args: any[]): void; + q: any[]; +}; + +interface Navigator { + readonly standalone: boolean; +} + +declare module 'preact/debug' {} diff --git a/src/client/tmp.png b/src/client/tmp.png deleted file mode 100644 index cdc60432..00000000 Binary files a/src/client/tmp.png and /dev/null differ diff --git a/src/static-build/assets/favicon.ico b/src/static-build/assets/favicon.ico new file mode 100644 index 00000000..a3a6c2d7 Binary files /dev/null and b/src/static-build/assets/favicon.ico differ diff --git a/src/static-build/components/base/index.tsx b/src/static-build/components/base/index.tsx deleted file mode 100644 index d0271db9..00000000 --- a/src/static-build/components/base/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright 2020 Google Inc. All Rights Reserved. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { h, FunctionalComponent, RenderableProps } from 'preact'; -import styles from 'css-bundle:./all.css'; -import clientBundleURL, { imports } from 'client-bundle:client/index.tsx'; - -interface Props { - title?: string; -} - -const BasePage: FunctionalComponent = ({ - children, - title, -}: RenderableProps) => { - return ( - - - {title ? `${title} - ` : ''}Squoosh - - -