From f96ae9bdeedd1ca170ff049d081dd0d92514e95a Mon Sep 17 00:00:00 2001 From: Jake Archibald Date: Fri, 25 Sep 2020 13:04:39 +0100 Subject: [PATCH] wip --- .gitignore | 2 + lib/image-worker-plugin.js | 47 +- src/client/initial-app/App/index.tsx | 8 +- .../Compress/custom-els/MultiPanel/index.ts | 321 ++++++++ .../custom-els/MultiPanel/missing-types.d.ts | 13 + .../Compress/custom-els/MultiPanel/styles.css | 10 + src/client/lazy-app/Compress/index.tsx | 774 ++++++++++++++++++ src/client/lazy-app/Compress/result-cache.ts | 77 ++ src/client/lazy-app/Compress/style.scss | 75 ++ src/client/lazy-app/util.ts | 61 ++ src/features/worker/bridge/index.ts | 65 ++ src/features/worker/bridge/missing-types.d.ts | 13 + 12 files changed, 1458 insertions(+), 8 deletions(-) create mode 100644 src/client/lazy-app/Compress/custom-els/MultiPanel/index.ts create mode 100644 src/client/lazy-app/Compress/custom-els/MultiPanel/missing-types.d.ts create mode 100644 src/client/lazy-app/Compress/custom-els/MultiPanel/styles.css create mode 100644 src/client/lazy-app/Compress/index.tsx create mode 100644 src/client/lazy-app/Compress/result-cache.ts create mode 100644 src/client/lazy-app/Compress/style.scss create mode 100644 src/client/lazy-app/util.ts create mode 100644 src/features/worker/bridge/index.ts create mode 100644 src/features/worker/bridge/missing-types.d.ts diff --git a/.gitignore b/.gitignore index 63646f64..341b505a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ build # Auto-generated by lib/image-worker-plugin.js src/features/worker/index.ts src/features/worker/tsconfig.json +src/features/worker/bridge/meta.ts +src/features/worker/bridge/tsconfig.json diff --git a/lib/image-worker-plugin.js b/lib/image-worker-plugin.js index 3cf41cb4..faca947a 100644 --- a/lib/image-worker-plugin.js +++ b/lib/image-worker-plugin.js @@ -62,22 +62,61 @@ export default function () { if (previousWorkerContent === workerFile) return; previousWorkerContent = workerFile; - const tsConfig = { + const tsConfigReferences = tsImports.map((tsImport) => ({ + path: path.dirname(tsImport), + })); + + const workerTsConfig = { extends: '../../../generic-tsconfig.json', compilerOptions: { lib: ['webworker', 'esnext'], }, - references: tsImports.map((tsImport) => ({ - path: path.dirname(tsImport), + references: tsConfigReferences, + }; + + const bridgeTsConfig = { + extends: '../../../../generic-tsconfig.json', + compilerOptions: { + lib: ['esnext', 'dom', 'dom.iterable'], + types: [], + }, + include: ['../../../client/lazy-app/util.ts', '**/*.ts'], + references: tsConfigReferences.map((ref) => ({ + path: path.join('..', ref.path), })), }; + const bridgeMeta = [ + `// This file is autogenerated by lib/image-worker-plugin.js`, + tsNames.map(([path, name]) => `import type ${name} from '../${path}';`), + `export const methodNames = ${JSON.stringify( + tsNames.map(([_, name]) => name), + null, + ' ', + )} as const;`, + `export interface BridgeMethods {`, + tsNames.map(([_, name]) => [ + ` ${name}(`, + ` signal: AbortSignal,`, + ` ...args: Parameters`, + ` ): Promise>;`, + ]), + `}`, + ] + .flat(Infinity) + .join('\n'); + await Promise.all([ fsp.writeFile( path.join(base, 'tsconfig.json'), - JSON.stringify(tsConfig, null, ' '), + JSON.stringify(workerTsConfig, null, ' '), + ), + fsp.writeFile( + path.join(base, 'bridge', 'tsconfig.json'), + JSON.stringify(bridgeTsConfig, null, ' '), ), fsp.writeFile(path.join(base, 'index.ts'), workerFile), + fsp.writeFile(path.join(base, 'bridge', 'meta.ts'), bridgeMeta), ]); }, }; diff --git a/src/client/initial-app/App/index.tsx b/src/client/initial-app/App/index.tsx index 05d25ffc..a91ce7be 100644 --- a/src/client/initial-app/App/index.tsx +++ b/src/client/initial-app/App/index.tsx @@ -14,10 +14,10 @@ import 'shared/initial-app/custom-els/loading-spinner'; const ROUTE_EDITOR = '/editor'; -//const compressPromise = import('../compress'); +const compressPromise = import('client/lazy-app/Compress'); const swBridgePromise = import('client/lazy-app/sw-bridge'); -console.log(swBridgePromise); +console.log(compressPromise); function back() { window.history.back(); @@ -53,7 +53,7 @@ export default class App extends Component { }) .catch(() => { this.showSnack('Failed to load app'); - }); + });*/ swBridgePromise.then(async ({ offliner, getSharedImage }) => { offliner(this.showSnack); @@ -63,7 +63,7 @@ export default class App extends Component { 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 diff --git a/src/client/lazy-app/Compress/custom-els/MultiPanel/index.ts b/src/client/lazy-app/Compress/custom-els/MultiPanel/index.ts new file mode 100644 index 00000000..d8ddfd7b --- /dev/null +++ b/src/client/lazy-app/Compress/custom-els/MultiPanel/index.ts @@ -0,0 +1,321 @@ +import * as style from './styles.css'; +import 'add-css:./styles.css'; +import { transitionHeight } from 'client/lazy-app/util'; + +interface CloseAllOptions { + exceptFirst?: boolean; +} + +const openOneOnlyAttr = 'open-one-only'; + +function getClosestHeading(el: Element): HTMLElement | undefined { + // Look for the child of multi-panel, but stop at interactive elements like links & buttons + const closestEl = el.closest('multi-panel > *, a, button'); + if (closestEl && closestEl.classList.contains(style.panelHeading)) { + return closestEl as HTMLElement; + } + return undefined; +} + +async function close(heading: HTMLElement) { + const content = heading.nextElementSibling as HTMLElement; + + // if there is no content, nothing to expand + if (!content) return; + + const from = content.getBoundingClientRect().height; + + heading.removeAttribute('content-expanded'); + content.setAttribute('aria-expanded', 'false'); + + // Wait a microtask so other calls to open/close can get the final sizes. + await null; + + await transitionHeight(content, { + from, + to: 0, + duration: 300, + }); + + content.style.height = ''; +} + +async function open(heading: HTMLElement) { + const content = heading.nextElementSibling as HTMLElement; + + // if there is no content, nothing to expand + if (!content) return; + + const from = content.getBoundingClientRect().height; + + heading.setAttribute('content-expanded', ''); + content.setAttribute('aria-expanded', 'true'); + + const to = content.getBoundingClientRect().height; + + // Wait a microtask so other calls to open/close can get the final sizes. + await null; + + await transitionHeight(content, { + from, + to, + duration: 300, + }); + + content.style.height = ''; +} + +/** + * A multi-panel view that the user can add any number of 'panels'. + * 'a panel' consists of two elements. Even index element becomes heading, + * and odd index element becomes the expandable content. + */ +export default class MultiPanel extends HTMLElement { + static get observedAttributes() { + return [openOneOnlyAttr]; + } + + constructor() { + super(); + + // add EventListeners + this.addEventListener('click', this._onClick); + this.addEventListener('keydown', this._onKeyDown); + + // Watch for children changes. + new MutationObserver(() => this._childrenChange()).observe(this, { + childList: true, + }); + } + + connectedCallback() { + this._childrenChange(); + } + + attributeChangedCallback( + name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (name === openOneOnlyAttr && newValue === null) { + this._closeAll({ exceptFirst: true }); + } + } + + // Click event handler + private _onClick(event: MouseEvent) { + const el = event.target as HTMLElement; + const heading = getClosestHeading(el); + if (!heading) return; + this._toggle(heading); + } + + // KeyDown event handler + private _onKeyDown(event: KeyboardEvent) { + const selectedEl = document.activeElement!; + const heading = getClosestHeading(selectedEl); + + // if keydown event is not on heading element, ignore + if (!heading) return; + + // if something inside of heading has focus, ignore + if (selectedEl !== heading) return; + + // don’t handle modifier shortcuts used by assistive technology. + if (event.altKey) return; + + let newHeading: HTMLElement | undefined; + + switch (event.key) { + case 'ArrowLeft': + case 'ArrowUp': + newHeading = this._prevHeading(); + break; + + case 'ArrowRight': + case 'ArrowDown': + newHeading = this._nextHeading(); + break; + + case 'Home': + newHeading = this._firstHeading(); + break; + + case 'End': + newHeading = this._lastHeading(); + break; + + // this has 3 cases listed to support IEs and FF before 37 + case 'Enter': + case ' ': + case 'Spacebar': + this._toggle(heading); + break; + + // Any other key press is ignored and passed back to the browser. + default: + return; + } + + event.preventDefault(); + if (newHeading) { + selectedEl.setAttribute('tabindex', '-1'); + newHeading.setAttribute('tabindex', '0'); + newHeading.focus(); + } + } + + private _toggle(heading: HTMLElement) { + if (!heading) return; + + // toggle expanded and aria-expanded attributes + if (heading.hasAttribute('content-expanded')) { + close(heading); + } else { + if (this.openOneOnly) this._closeAll(); + open(heading); + } + } + + private _closeAll(options: CloseAllOptions = {}): void { + const { exceptFirst = false } = options; + let els = [...this.children].filter((el) => + el.matches('[content-expanded]'), + ) as HTMLElement[]; + + if (exceptFirst) { + els = els.slice(1); + } + + for (const el of els) close(el); + } + + // children of multi-panel should always be even number (heading/content pair) + private _childrenChange() { + let preserveTabIndex = false; + let heading = this.firstElementChild; + + while (heading) { + const content = heading.nextElementSibling; + const randomId = Math.random().toString(36).substr(2, 9); + + // if at the end of this loop, runout of element for content, + // it means it has odd number of elements. log error and set heading to end the loop. + if (!content) { + console.error( + ' requires an even number of element children.', + ); + break; + } + + // When odd number of elements were inserted in the middle, + // what was heading before may become content after the insertion. + // Remove classes and attributes to prepare for this change. + heading.classList.remove(style.panelContent); + content.classList.remove(style.panelHeading); + heading.removeAttribute('aria-expanded'); + heading.removeAttribute('content-expanded'); + + // If appreciable, remove tabindex from content which used to be header. + content.removeAttribute('tabindex'); + + // Assign heading and content classes + heading.classList.add(style.panelHeading); + content.classList.add(style.panelContent); + + // Assign ids and aria-X for heading/content pair. + heading.id = `panel-heading-${randomId}`; + heading.setAttribute('aria-controls', `panel-content-${randomId}`); + content.id = `panel-content-${randomId}`; + content.setAttribute('aria-labelledby', `panel-heading-${randomId}`); + + // If tabindex 0 is assigned to a heading, flag to preserve tab index position. + // Otherwise, make sure tabindex -1 is set to heading elements. + if (heading.getAttribute('tabindex') === '0') { + preserveTabIndex = true; + } else { + heading.setAttribute('tabindex', '-1'); + } + + // It's possible that the heading & content expanded attributes are now out of sync. Resync + // them using the heading as the source of truth. + content.setAttribute( + 'aria-expanded', + heading.hasAttribute('content-expanded') ? 'true' : 'false', + ); + + // next sibling of content = next heading + heading = content.nextElementSibling; + } + + // if no flag, make 1st heading as tabindex 0 (otherwise keep previous tab index position). + if (!preserveTabIndex && this.firstElementChild) { + this.firstElementChild.setAttribute('tabindex', '0'); + } + + // In case we're openOneOnly, and an additional open item has been added: + if (this.openOneOnly) this._closeAll({ exceptFirst: true }); + } + + // returns heading that is before currently selected one. + private _prevHeading() { + // activeElement would be the currently selected heading + // 2 elements before that would be the previous heading unless it is the first element. + if (this.firstElementChild === document.activeElement) { + return this.firstElementChild as HTMLElement; + } + // previous Element of active Element is previous Content, + // previous Element of previous Content is previousHeading + const previousContent = document.activeElement!.previousElementSibling; + if (previousContent) { + return previousContent.previousElementSibling as HTMLElement; + } + } + + // returns heading that is after currently selected one. + private _nextHeading() { + // activeElement would be the currently selected heading + // 2 elemements after that would be the next heading. + const nextContent = document.activeElement!.nextElementSibling; + if (nextContent) { + return nextContent.nextElementSibling as HTMLElement; + } + } + + // returns first heading in multi-panel. + private _firstHeading() { + // first element is always first heading + return this.firstElementChild as HTMLElement; + } + + // returns last heading in multi-panel. + private _lastHeading() { + // if the last element is heading, return last element + const lastEl = this.lastElementChild as HTMLElement; + if (lastEl && lastEl.classList.contains(style.panelHeading)) { + return lastEl; + } + // otherwise return 2nd from the last + const lastContent = this.lastElementChild; + if (lastContent) { + return lastContent.previousElementSibling as HTMLElement; + } + } + + /** + * If true, only one panel can be open at once. When one opens, others close. + */ + get openOneOnly() { + return this.hasAttribute(openOneOnlyAttr); + } + + set openOneOnly(val: boolean) { + if (val) { + this.setAttribute(openOneOnlyAttr, ''); + } else { + this.removeAttribute(openOneOnlyAttr); + } + } +} + +customElements.define('multi-panel', MultiPanel); diff --git a/src/client/lazy-app/Compress/custom-els/MultiPanel/missing-types.d.ts b/src/client/lazy-app/Compress/custom-els/MultiPanel/missing-types.d.ts new file mode 100644 index 00000000..416a22a0 --- /dev/null +++ b/src/client/lazy-app/Compress/custom-els/MultiPanel/missing-types.d.ts @@ -0,0 +1,13 @@ +interface MultiPanelAttributes extends preact.JSX.HTMLAttributes { + 'open-one-only'?: boolean; +} + +declare module 'preact' { + namespace createElement.JSX { + interface IntrinsicElements { + 'multi-panel': MultiPanelAttributes; + } + } +} + +export {}; diff --git a/src/client/lazy-app/Compress/custom-els/MultiPanel/styles.css b/src/client/lazy-app/Compress/custom-els/MultiPanel/styles.css new file mode 100644 index 00000000..0efe86ce --- /dev/null +++ b/src/client/lazy-app/Compress/custom-els/MultiPanel/styles.css @@ -0,0 +1,10 @@ +.panel-heading { + background: gray; +} +.panel-content { + height: 0px; + overflow: auto; +} +.panel-content[aria-expanded='true'] { + height: auto; +} diff --git a/src/client/lazy-app/Compress/index.tsx b/src/client/lazy-app/Compress/index.tsx new file mode 100644 index 00000000..e332d5b9 --- /dev/null +++ b/src/client/lazy-app/Compress/index.tsx @@ -0,0 +1,774 @@ +import { h, Component } from 'preact'; + +import { bind, Fileish } from '../../lib/initial-util'; +import { blobToImg, drawableToImageData, blobToText } from '../../lib/util'; +import * as style from './style.scss'; +import Output from '../Output'; +import Options from '../Options'; +import ResultCache from './result-cache'; +import * as identity from '../../codecs/identity/encoder-meta'; +import * as oxiPNG from '../../codecs/oxipng/encoder-meta'; +import * as mozJPEG from '../../codecs/mozjpeg/encoder-meta'; +import * as webP from '../../codecs/webp/encoder-meta'; +import * as avif from '../../codecs/avif/encoder-meta'; +import * as browserPNG from '../../codecs/browser-png/encoder-meta'; +import * as browserJPEG from '../../codecs/browser-jpeg/encoder-meta'; +import * as browserWebP from '../../codecs/browser-webp/encoder-meta'; +import * as browserGIF from '../../codecs/browser-gif/encoder-meta'; +import * as browserTIFF from '../../codecs/browser-tiff/encoder-meta'; +import * as browserJP2 from '../../codecs/browser-jp2/encoder-meta'; +import * as browserBMP from '../../codecs/browser-bmp/encoder-meta'; +import * as browserPDF from '../../codecs/browser-pdf/encoder-meta'; +import { + EncoderState, + EncoderType, + EncoderOptions, + encoderMap, +} from '../../codecs/encoders'; +import { + PreprocessorState, + defaultPreprocessorState, +} from '../../codecs/preprocessors'; +import { decodeImage } from '../../codecs/decoders'; +import { cleanMerge, cleanSet } from '../../lib/clean-modify'; +import Processor from '../../codecs/processor'; +import { + BrowserResizeOptions, + isWorkerOptions as isWorkerResizeOptions, + isHqx, + WorkerResizeOptions, +} from '../../codecs/resize/processor-meta'; +import './custom-els/MultiPanel'; +import Results from '../results'; +import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons'; +import SnackBarElement from '../../lib/SnackBar'; +import { + InputProcessorState, + defaultInputProcessorState, +} from '../../codecs/input-processors'; + +export interface SourceImage { + file: File | Fileish; + decoded: ImageData; + processed: ImageData; + vectorImage?: HTMLImageElement; + inputProcessorState: InputProcessorState; +} + +interface SideSettings { + preprocessorState: PreprocessorState; + encoderState: EncoderState; +} + +interface Side { + preprocessed?: ImageData; + file?: Fileish; + downloadUrl?: string; + data?: ImageData; + latestSettings: SideSettings; + encodedSettings?: SideSettings; + loading: boolean; + /** Counter of the latest bmp currently encoding */ + loadingCounter: number; + /** Counter of the latest bmp encoded */ + loadedCounter: number; +} + +interface Props { + file: File | Fileish; + showSnack: SnackBarElement['showSnackbar']; + onBack: () => void; +} + +interface State { + source?: SourceImage; + sides: [Side, Side]; + /** Source image load */ + loading: boolean; + loadingCounter: number; + error?: string; + mobileView: boolean; +} + +interface UpdateImageOptions { + skipPreprocessing?: boolean; +} + +async function processInput( + data: ImageData, + inputProcessData: InputProcessorState, + processor: Processor, +) { + let processedData = data; + + if (inputProcessData.rotate.rotate !== 0) { + processedData = await processor.rotate( + processedData, + inputProcessData.rotate, + ); + } + + return processedData; +} + +async function preprocessImage( + source: SourceImage, + preprocessData: PreprocessorState, + processor: Processor, +): Promise { + let result = source.processed; + + if (preprocessData.resize.enabled) { + if (preprocessData.resize.method === 'vector' && source.vectorImage) { + result = processor.vectorResize( + source.vectorImage, + preprocessData.resize, + ); + } else if (isHqx(preprocessData.resize)) { + // Hqx can only do x2, x3 or x4. + result = await processor.workerResize(result, preprocessData.resize); + // If the target size is not a clean x2, x3 or x4, use Catmull-Rom + // for the remaining scaling. + const pixelOpts = { ...preprocessData.resize, method: 'catrom' }; + result = await processor.workerResize( + result, + pixelOpts as WorkerResizeOptions, + ); + } else if (isWorkerResizeOptions(preprocessData.resize)) { + result = await processor.workerResize(result, preprocessData.resize); + } else { + result = processor.resize( + result, + preprocessData.resize as BrowserResizeOptions, + ); + } + } + if (preprocessData.quantizer.enabled) { + result = await processor.imageQuant(result, preprocessData.quantizer); + } + return result; +} + +async function compressImage( + image: ImageData, + encodeData: EncoderState, + sourceFilename: string, + processor: Processor, +): Promise { + const compressedData = await (() => { + switch (encodeData.type) { + case oxiPNG.type: + return processor.oxiPngEncode(image, encodeData.options); + case mozJPEG.type: + return processor.mozjpegEncode(image, encodeData.options); + case webP.type: + return processor.webpEncode(image, encodeData.options); + case avif.type: + return processor.avifEncode(image, encodeData.options); + case browserPNG.type: + return processor.browserPngEncode(image); + case browserJPEG.type: + return processor.browserJpegEncode(image, encodeData.options); + case browserWebP.type: + return processor.browserWebpEncode(image, encodeData.options); + case browserGIF.type: + return processor.browserGifEncode(image); + case browserTIFF.type: + return processor.browserTiffEncode(image); + case browserJP2.type: + return processor.browserJp2Encode(image); + case browserBMP.type: + return processor.browserBmpEncode(image); + case browserPDF.type: + return processor.browserPdfEncode(image); + default: + throw Error(`Unexpected encoder ${JSON.stringify(encodeData)}`); + } + })(); + + const encoder = encoderMap[encodeData.type]; + + return new Fileish( + [compressedData], + sourceFilename.replace(/.[^.]*$/, `.${encoder.extension}`), + { type: encoder.mimeType }, + ); +} + +function stateForNewSourceData(state: State, newSource: SourceImage): State { + let newState = { ...state }; + + for (const i of [0, 1]) { + // Ditch previous encodings + const downloadUrl = state.sides[i].downloadUrl; + if (downloadUrl) URL.revokeObjectURL(downloadUrl); + + newState = cleanMerge(state, `sides.${i}`, { + preprocessed: undefined, + file: undefined, + downloadUrl: undefined, + data: undefined, + encodedSettings: undefined, + }); + } + + return newState; +} + +async function processSvg(blob: Blob): Promise { + // Firefox throws if you try to draw an SVG to canvas that doesn't have width/height. + // In Chrome it loads, but drawImage behaves weirdly. + // This function sets width/height if it isn't already set. + const parser = new DOMParser(); + const text = await blobToText(blob); + const document = parser.parseFromString(text, 'image/svg+xml'); + const svg = document.documentElement!; + + if (svg.hasAttribute('width') && svg.hasAttribute('height')) { + return blobToImg(blob); + } + + const viewBox = svg.getAttribute('viewBox'); + if (viewBox === null) throw Error('SVG must have width/height or viewBox'); + + const viewboxParts = viewBox.split(/\s+/); + svg.setAttribute('width', viewboxParts[2]); + svg.setAttribute('height', viewboxParts[3]); + + const serializer = new XMLSerializer(); + const newSource = serializer.serializeToString(document); + return blobToImg(new Blob([newSource], { type: 'image/svg+xml' })); +} + +// These are only used in the mobile view +const resultTitles = ['Top', 'Bottom']; +// These are only used in the desktop view +const buttonPositions = ['download-left', 'download-right'] as ( + | 'download-left' + | 'download-right' +)[]; + +const originalDocumentTitle = document.title; + +export default class Compress extends Component { + widthQuery = window.matchMedia('(max-width: 599px)'); + + state: State = { + source: undefined, + loading: false, + loadingCounter: 0, + sides: [ + { + latestSettings: { + preprocessorState: defaultPreprocessorState, + encoderState: { + type: identity.type, + options: identity.defaultOptions, + }, + }, + loadingCounter: 0, + loadedCounter: 0, + loading: false, + }, + { + latestSettings: { + preprocessorState: defaultPreprocessorState, + encoderState: { type: mozJPEG.type, options: mozJPEG.defaultOptions }, + }, + loadingCounter: 0, + loadedCounter: 0, + loading: false, + }, + ], + mobileView: this.widthQuery.matches, + }; + + private readonly encodeCache = new ResultCache(); + private readonly leftProcessor = new Processor(); + private readonly rightProcessor = new Processor(); + // For debouncing calls to updateImage for each side. + private readonly updateImageTimeoutIds: [number?, number?] = [ + undefined, + undefined, + ]; + + constructor(props: Props) { + super(props); + this.widthQuery.addListener(this.onMobileWidthChange); + this.updateFile(props.file); + + import('../../lib/sw-bridge').then(({ mainAppLoaded }) => mainAppLoaded()); + } + + @bind + private onMobileWidthChange() { + this.setState({ mobileView: this.widthQuery.matches }); + } + + private onEncoderTypeChange(index: 0 | 1, newType: EncoderType): void { + this.setState({ + sides: cleanSet( + this.state.sides, + `${index}.latestSettings.encoderState`, + { + type: newType, + options: encoderMap[newType].defaultOptions, + }, + ), + }); + } + + private onPreprocessorOptionsChange( + index: 0 | 1, + options: PreprocessorState, + ): void { + this.setState({ + sides: cleanSet( + this.state.sides, + `${index}.latestSettings.preprocessorState`, + options, + ), + }); + } + + private onEncoderOptionsChange(index: 0 | 1, options: EncoderOptions): void { + this.setState({ + sides: cleanSet( + this.state.sides, + `${index}.latestSettings.encoderState.options`, + options, + ), + }); + } + + private updateDocumentTitle(filename: string = ''): void { + document.title = filename + ? `${filename} - ${originalDocumentTitle}` + : originalDocumentTitle; + } + + componentWillReceiveProps(nextProps: Props): void { + if (nextProps.file !== this.props.file) { + this.updateFile(nextProps.file); + } + } + + componentWillUnmount(): void { + this.updateDocumentTitle(); + } + + componentDidUpdate(prevProps: Props, prevState: State): void { + const { source, sides } = this.state; + + const sourceDataChanged = + // Has the source object become set/unset? + !!source !== !!prevState.source || + // Or has the processed data changed? + (source && + prevState.source && + source.processed !== prevState.source.processed); + + for (const [i, side] of sides.entries()) { + const prevSettings = prevState.sides[i].latestSettings; + const encoderChanged = + side.latestSettings.encoderState !== prevSettings.encoderState; + const preprocessorChanged = + side.latestSettings.preprocessorState !== + prevSettings.preprocessorState; + + // The image only needs updated if the encoder/preprocessor settings have changed, or the + // source has changed. + if (sourceDataChanged || encoderChanged || preprocessorChanged) { + this.queueUpdateImage(i, { + skipPreprocessing: !sourceDataChanged && !preprocessorChanged, + }); + } + } + } + + private async onCopyToOtherClick(index: 0 | 1) { + const otherIndex = (index + 1) % 2; + const oldSettings = this.state.sides[otherIndex]; + const newSettings = { ...this.state.sides[index] }; + + // Create a new object URL for the new settings. This avoids both sides sharing a URL, which + // means it can be safely revoked without impacting the other side. + if (newSettings.file) + newSettings.downloadUrl = URL.createObjectURL(newSettings.file); + + this.setState({ + sides: cleanSet(this.state.sides, otherIndex, newSettings), + }); + + const result = await this.props.showSnack('Settings copied across', { + timeout: 5000, + actions: ['undo', 'dismiss'], + }); + + if (result !== 'undo') return; + + this.setState({ + sides: cleanSet(this.state.sides, otherIndex, oldSettings), + }); + } + + @bind + private async onInputProcessorChange( + options: InputProcessorState, + ): Promise { + const source = this.state.source; + if (!source) return; + + const oldRotate = source.inputProcessorState.rotate.rotate; + const newRotate = options.rotate.rotate; + const orientationChanged = oldRotate % 180 !== newRotate % 180; + const loadingCounter = this.state.loadingCounter + 1; + // Either processor is good enough here. + const processor = this.leftProcessor; + + this.setState({ + loadingCounter, + loading: true, + source: cleanSet(source, 'inputProcessorState', options), + }); + + // Abort any current encode jobs, as they're redundant now. + this.leftProcessor.abortCurrent(); + this.rightProcessor.abortCurrent(); + + try { + const processed = await processInput(source.decoded, options, processor); + + // Another file has been opened/processed before this one processed. + if (this.state.loadingCounter !== loadingCounter) return; + + let newState = { ...this.state, loading: false }; + newState = cleanSet(newState, 'source.processed', processed); + newState = stateForNewSourceData(newState, newState.source!); + + if (orientationChanged) { + // If orientation has changed, we should flip the resize values. + for (const i of [0, 1]) { + const resizeSettings = + newState.sides[i].latestSettings.preprocessorState.resize; + newState = cleanMerge( + newState, + `sides.${i}.latestSettings.preprocessorState.resize`, + { + width: resizeSettings.height, + height: resizeSettings.width, + }, + ); + } + } + this.setState(newState); + } catch (err) { + if (err.name === 'AbortError') return; + console.error(err); + // Another file has been opened/processed before this one processed. + if (this.state.loadingCounter !== loadingCounter) return; + this.props.showSnack('Processing error'); + this.setState({ loading: false }); + } + } + + @bind + private async updateFile(file: File | Fileish) { + const loadingCounter = this.state.loadingCounter + 1; + // Either processor is good enough here. + const processor = this.leftProcessor; + + this.setState({ loadingCounter, loading: true }); + + // Abort any current encode jobs, as they're redundant now. + this.leftProcessor.abortCurrent(); + this.rightProcessor.abortCurrent(); + + try { + let decoded: ImageData; + let vectorImage: HTMLImageElement | undefined; + + // Special-case SVG. We need to avoid createImageBitmap because of + // https://bugs.chromium.org/p/chromium/issues/detail?id=606319. + // Also, we cache the HTMLImageElement so we can perform vector resizing later. + if (file.type.startsWith('image/svg+xml')) { + vectorImage = await processSvg(file); + decoded = drawableToImageData(vectorImage); + } else { + // Either processor is good enough here. + decoded = await decodeImage(file, processor); + } + + const processed = await processInput( + decoded, + defaultInputProcessorState, + processor, + ); + + // Another file has been opened/processed before this one processed. + if (this.state.loadingCounter !== loadingCounter) return; + + let newState: State = { + ...this.state, + source: { + decoded, + file, + vectorImage, + processed, + inputProcessorState: defaultInputProcessorState, + }, + loading: false, + }; + + newState = stateForNewSourceData(newState, newState.source!); + + for (const i of [0, 1]) { + // Default resize values come from the image: + newState = cleanMerge( + newState, + `sides.${i}.latestSettings.preprocessorState.resize`, + { + width: processed.width, + height: processed.height, + method: vectorImage ? 'vector' : 'lanczos3', + }, + ); + } + + this.updateDocumentTitle(file.name); + this.setState(newState); + } catch (err) { + if (err.name === 'AbortError') return; + console.error(err); + // Another file has been opened/processed before this one processed. + if (this.state.loadingCounter !== loadingCounter) return; + this.props.showSnack('Invalid image'); + this.setState({ loading: false }); + } + } + + /** + * Debounce the heavy lifting of updateImage. + * Otherwise, the thrashing causes jank, and sometimes crashes iOS Safari. + */ + private queueUpdateImage( + index: number, + options: UpdateImageOptions = {}, + ): void { + // Call updateImage after this delay, unless queueUpdateImage is called again, in which case the + // timeout is reset. + const delay = 100; + + clearTimeout(this.updateImageTimeoutIds[index]); + + this.updateImageTimeoutIds[index] = self.setTimeout(() => { + this.updateImage(index, options).catch((err) => { + console.error(err); + }); + }, delay); + } + + private async updateImage( + index: number, + options: UpdateImageOptions = {}, + ): Promise { + const { skipPreprocessing = false } = options; + const { source } = this.state; + if (!source) return; + + // Each time we trigger an async encode, the counter changes. + const loadingCounter = this.state.sides[index].loadingCounter + 1; + + let sides = cleanMerge(this.state.sides, index, { + loadingCounter, + loading: true, + }); + + this.setState({ sides }); + + const side = sides[index]; + const settings = side.latestSettings; + + let file: File | Fileish | undefined; + let preprocessed: ImageData | undefined; + let data: ImageData | undefined; + const cacheResult = this.encodeCache.match( + source.processed, + settings.preprocessorState, + settings.encoderState, + ); + const processor = index === 0 ? this.leftProcessor : this.rightProcessor; + + // Abort anything the processor is currently doing. + // Although the processor will abandon current tasks when a new one is called, + // we might not call another task here. Eg, we might get the result from the cache. + processor.abortCurrent(); + + if (cacheResult) { + ({ file, preprocessed, data } = cacheResult); + } else { + try { + // Special case for identity + if (settings.encoderState.type === identity.type) { + file = source.file; + data = source.processed; + } else { + preprocessed = + skipPreprocessing && side.preprocessed + ? side.preprocessed + : await preprocessImage( + source, + settings.preprocessorState, + processor, + ); + + file = await compressImage( + preprocessed, + settings.encoderState, + source.file.name, + processor, + ); + data = await decodeImage(file, processor); + + this.encodeCache.add({ + data, + preprocessed, + file, + sourceData: source.processed, + encoderState: settings.encoderState, + preprocessorState: settings.preprocessorState, + }); + } + } catch (err) { + if (err.name === 'AbortError') return; + this.props.showSnack( + `Processing error (type=${settings.encoderState.type}): ${err}`, + ); + throw err; + } + } + + const latestData = this.state.sides[index]; + // If a later encode has landed before this one, return. + if (loadingCounter < latestData.loadedCounter) { + return; + } + + if (latestData.downloadUrl) URL.revokeObjectURL(latestData.downloadUrl); + + sides = cleanMerge(this.state.sides, index, { + file, + data, + preprocessed, + downloadUrl: URL.createObjectURL(file), + loading: sides[index].loadingCounter !== loadingCounter, + loadedCounter: loadingCounter, + encodedSettings: settings, + }); + + this.setState({ sides }); + } + + render({ onBack }: Props, { loading, sides, source, mobileView }: State) { + const [leftSide, rightSide] = sides; + const [leftImageData, rightImageData] = sides.map((i) => i.data); + + const options = sides.map((side, index) => ( + // tslint:disable-next-line:jsx-key + + )); + + const copyDirections = (mobileView + ? ['down', 'up'] + : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][]; + + const results = sides.map((side, index) => ( + // tslint:disable-next-line:jsx-key + + {!mobileView + ? null + : [ + , + `${resultTitles[index]} (${ + encoderMap[side.latestSettings.encoderState.type].label + })`, + ]} + + )); + + // For rendering, we ideally want the settings that were used to create the data, not the latest + // settings. + const leftDisplaySettings = + leftSide.encodedSettings || leftSide.latestSettings; + const rightDisplaySettings = + rightSide.encodedSettings || rightSide.latestSettings; + const leftImgContain = + leftDisplaySettings.preprocessorState.resize.enabled && + leftDisplaySettings.preprocessorState.resize.fitMethod === 'contain'; + const rightImgContain = + rightDisplaySettings.preprocessorState.resize.enabled && + rightDisplaySettings.preprocessorState.resize.fitMethod === 'contain'; + + return ( +
+ + {mobileView ? ( +
+ + {results[0]} + {options[0]} + {results[1]} + {options[1]} + +
+ ) : ( + [ +
+ {options[0]} + {results[0]} +
, +
+ {options[1]} + {results[1]} +
, + ] + )} +
+ ); + } +} diff --git a/src/client/lazy-app/Compress/result-cache.ts b/src/client/lazy-app/Compress/result-cache.ts new file mode 100644 index 00000000..dfef306e --- /dev/null +++ b/src/client/lazy-app/Compress/result-cache.ts @@ -0,0 +1,77 @@ +import { EncoderState } from '../../codecs/encoders'; +import { shallowEqual } from '../../lib/util'; +import { PreprocessorState } from '../../codecs/preprocessors'; + +import * as identity from '../../codecs/identity/encoder-meta'; + +interface CacheResult { + preprocessed: ImageData; + data: ImageData; + file: Fileish; +} + +interface CacheEntry extends CacheResult { + preprocessorState: PreprocessorState; + encoderState: EncoderState; + sourceData: ImageData; +} + +const SIZE = 5; + +export default class ResultCache { + private readonly _entries: CacheEntry[] = []; + + add(entry: CacheEntry) { + if (entry.encoderState.type === identity.type) + throw Error('Cannot cache identity encodes'); + // Add the new entry to the start + this._entries.unshift(entry); + // Remove the last entry if we're now bigger than SIZE + if (this._entries.length > SIZE) this._entries.pop(); + } + + match( + sourceData: ImageData, + preprocessorState: PreprocessorState, + encoderState: EncoderState, + ): CacheResult | undefined { + const matchingIndex = this._entries.findIndex((entry) => { + // Check for quick exits: + if (entry.sourceData !== sourceData) return false; + if (entry.encoderState.type !== encoderState.type) return false; + + // Check that each set of options in the preprocessor are the same + for (const prop in preprocessorState) { + if ( + !shallowEqual( + (preprocessorState as any)[prop], + (entry.preprocessorState as any)[prop], + ) + ) + return false; + } + + // Check detailed encoder options + if (!shallowEqual(encoderState.options, entry.encoderState.options)) + return false; + + return true; + }); + + if (matchingIndex === -1) return undefined; + + const matchingEntry = this._entries[matchingIndex]; + + if (matchingIndex !== 0) { + // Move the matched result to 1st position (LRU) + this._entries.splice(matchingIndex, 1); + this._entries.unshift(matchingEntry); + } + + return { + data: matchingEntry.data, + preprocessed: matchingEntry.preprocessed, + file: matchingEntry.file, + }; + } +} diff --git a/src/client/lazy-app/Compress/style.scss b/src/client/lazy-app/Compress/style.scss new file mode 100644 index 00000000..3afe8eda --- /dev/null +++ b/src/client/lazy-app/Compress/style.scss @@ -0,0 +1,75 @@ +.compress { + width: 100%; + height: 100%; + contain: strict; + display: grid; + align-items: end; + align-content: end; + grid-template-rows: 1fr auto; + + @media (min-width: 600px) { + grid-template-columns: 1fr auto; + grid-template-rows: 100%; + } +} + +.options { + color: #fff; + opacity: 0.9; + font-size: 1.2rem; + display: flex; + flex-flow: column; + max-width: 400px; + margin: 0 auto; + width: calc(100% - 60px); + max-height: calc(100% - 104px); + overflow: hidden; + + @media (min-width: 600px) { + max-height: calc(100% - 75px); + width: 300px; + margin: 0; + } + + @media (min-width: 860px) { + max-height: calc(100% - 40px); + } +} + +.multi-panel { + position: relative; + display: flex; + flex-flow: column; + overflow: hidden; + + // Reorder so headings appear after content: + & > :nth-child(1) { + order: 2; + margin-bottom: 10px; + } + + & > :nth-child(2) { + order: 1; + } + + & > :nth-child(3) { + order: 4; + } + + & > :nth-child(4) { + order: 3; + } +} + +.expand-icon { + transform: rotate(180deg); + margin-left: -12px; +} + +[content-expanded] .expand-icon { + transform: none; +} + +:focus .expand-icon { + fill: #34B9EB; +} diff --git a/src/client/lazy-app/util.ts b/src/client/lazy-app/util.ts new file mode 100644 index 00000000..dc3180e7 --- /dev/null +++ b/src/client/lazy-app/util.ts @@ -0,0 +1,61 @@ +interface TransitionOptions { + from?: number; + to?: number; + duration?: number; + easing?: string; +} + +export async function transitionHeight( + el: HTMLElement, + opts: TransitionOptions, +): Promise { + const { + from = el.getBoundingClientRect().height, + to = el.getBoundingClientRect().height, + duration = 1000, + easing = 'ease-in-out', + } = opts; + + if (from === to || duration === 0) { + el.style.height = to + 'px'; + return; + } + + el.style.height = from + 'px'; + // Force a style calc so the browser picks up the start value. + getComputedStyle(el).transform; + el.style.transition = `height ${duration}ms ${easing}`; + el.style.height = to + 'px'; + + return new Promise((resolve) => { + const listener = (event: Event) => { + if (event.target !== el) return; + el.style.transition = ''; + el.removeEventListener('transitionend', listener); + el.removeEventListener('transitioncancel', listener); + resolve(); + }; + + el.addEventListener('transitionend', listener); + el.addEventListener('transitioncancel', listener); + }); +} + +/** + * Take a signal and promise, and returns a promise that rejects with an AbortError if the abort is + * signalled, otherwise resolves with the promise. + */ +export async function abortable( + signal: AbortSignal, + promise: Promise, +): Promise { + if (signal.aborted) throw new DOMException('AbortError', 'AbortError'); + return Promise.race([ + promise, + new Promise((_, reject) => { + signal.addEventListener('abort', () => + reject(new DOMException('AbortError', 'AbortError')), + ); + }), + ]); +} diff --git a/src/features/worker/bridge/index.ts b/src/features/worker/bridge/index.ts new file mode 100644 index 00000000..db716afa --- /dev/null +++ b/src/features/worker/bridge/index.ts @@ -0,0 +1,65 @@ +import { wrap } from 'comlink'; +import { BridgeMethods, methodNames } from './meta'; +import workerURL from 'omt:../index'; +import type { ProcessorWorkerApi } from '../'; +import { abortable } from '../../../client/lazy-app/util'; + +/** How long the worker should be idle before terminating. */ +const workerTimeout = 10000; + +interface WorkerBridge extends BridgeMethods {} + +class WorkerBridge { + protected _queue = Promise.resolve() as Promise; + /** Worker instance associated with this processor. */ + protected _worker?: Worker; + /** Comlinked worker API. */ + protected _workerApi?: ProcessorWorkerApi; + + protected _terminateWorker() { + if (!this._worker) return; + this._worker.terminate(); + this._worker = undefined; + this._workerApi = undefined; + } + + protected _startWorker() { + this._worker = new Worker(workerURL); + this._workerApi = wrap(this._worker); + } +} + +for (const methodName of methodNames) { + WorkerBridge.prototype[methodName] = function ( + this: WorkerBridge, + signal: AbortSignal, + ...args: any + ) { + this._queue = this._queue + .catch(() => {}) + .then(async () => { + if (signal.aborted) throw new DOMException('AbortError', 'AbortError'); + let done = false; + + signal.addEventListener('abort', () => { + if (done) return; + this._terminateWorker(); + }); + + if (!this._worker) this._startWorker(); + + const timeoutId = setTimeout(() => { + this._terminateWorker(); + }, workerTimeout); + + return abortable(signal, this._workerApi![methodName]() as any).finally( + () => { + done = true; + clearTimeout(timeoutId); + }, + ); + }); + } as any; +} + +export default WorkerBridge; diff --git a/src/features/worker/bridge/missing-types.d.ts b/src/features/worker/bridge/missing-types.d.ts new file mode 100644 index 00000000..e8e80d3a --- /dev/null +++ b/src/features/worker/bridge/missing-types.d.ts @@ -0,0 +1,13 @@ +/** + * 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. + */ +///