diff --git a/client-tsconfig.json b/client-tsconfig.json index c42935a3..54dfc176 100644 --- a/client-tsconfig.json +++ b/client-tsconfig.json @@ -7,12 +7,12 @@ "include": [ "src/features/**/client/**/*", "src/features/**/shared/**/*", - // Not really clean, but we need to access the type of the functions here + "src/features/client-utils/**/*", + "src/shared/**/*", + "src/client/**/*", + // Not really clean, but we need these to access the type of the functions // for comlink "src/features/**/worker/**/*", - // And again. - "src/features-worker/**/*", - "src/shared/**/*", - "src/client/**/*" + "src/features-worker/**/*" ] } diff --git a/lib/feature-plugin.js b/lib/feature-plugin.js index 46032210..b98ab466 100644 --- a/lib/feature-plugin.js +++ b/lib/feature-plugin.js @@ -214,6 +214,11 @@ export default function () { ([path, name]) => `import * as ${name}ProcessorMeta from '${path}';`, ), `interface Enableable { enabled: boolean; }`, + `export interface ProcessorOptions {`, + processorMetaTsNames.map( + ([_, name]) => ` ${name}: ${name}ProcessorMeta.Options;`, + ), + `}`, `export interface ProcessorState {`, processorMetaTsNames.map( ([_, name]) => ` ${name}: Enableable & ${name}ProcessorMeta.Options;`, diff --git a/package-lock.json b/package-lock.json index 38d95538..ddc8dd01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2312,6 +2312,12 @@ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "dev": true }, + "linkstate": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/linkstate/-/linkstate-1.1.1.tgz", + "integrity": "sha512-5SICdxQG9FpWk44wSEoM2WOCUNuYfClp10t51XAIV5E7vUILF/dTYxf0vJw6bW2dUd2wcQon+LkNtRijpNLrig==", + "dev": true + }, "lint-staged": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.4.0.tgz", diff --git a/package.json b/package.json index 776463b9..90000d7a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "file-drop-element": "^1.0.1", "husky": "^4.3.0", "idb-keyval": "^3.2.0", + "linkstate": "^1.1.1", "lint-staged": "^10.4.0", "lodash.camelcase": "^4.3.0", "mime-types": "^2.1.27", diff --git a/src/client/lazy-app/Compress/Options/Checkbox/index.tsx b/src/client/lazy-app/Compress/Options/Checkbox/index.tsx new file mode 100644 index 00000000..2b0790d1 --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Checkbox/index.tsx @@ -0,0 +1,23 @@ +import { h, Component } from 'preact'; +import * as style from './style.css'; +import 'add-css:./styles.css'; +import { UncheckedIcon, CheckedIcon } from '../../../icons'; + +interface Props extends preact.JSX.HTMLAttributes {} +interface State {} + +export default class Checkbox extends Component { + render(props: Props) { + return ( +
+ {props.checked ? ( + + ) : ( + + )} + {/* @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019 */} + +
+ ); + } +} diff --git a/src/client/lazy-app/Compress/Options/Checkbox/style.css b/src/client/lazy-app/Compress/Options/Checkbox/style.css new file mode 100644 index 00000000..c68bd895 --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Checkbox/style.css @@ -0,0 +1,22 @@ +.checkbox { + display: inline-block; + position: relative; + --size: 17px; +} + +.real-checkbox { + top: 0; + position: absolute; + opacity: 0; + pointer-events: none; +} + +.icon { + display: block; + width: var(--size); + height: var(--size); +} + +.checked { + fill: #34b9eb; +} diff --git a/src/client/lazy-app/Compress/Options/Expander/index.tsx b/src/client/lazy-app/Compress/Options/Expander/index.tsx new file mode 100644 index 00000000..cb497c96 --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Expander/index.tsx @@ -0,0 +1,80 @@ +import { h, Component, ComponentChild, ComponentChildren } from 'preact'; +import * as style from './style.css'; +import 'add-css:./styles.css'; +import { transitionHeight } from '../../../util'; + +interface Props { + children: ComponentChildren; +} +interface State { + outgoingChildren: ComponentChild[]; +} + +export default class Expander extends Component { + state: State = { + outgoingChildren: [], + }; + private lastElHeight: number = 0; + + componentWillReceiveProps(nextProps: Props) { + const children = this.props.children as ComponentChild[]; + const nextChildren = nextProps.children as ComponentChild[]; + + if (!nextChildren[0] && children[0]) { + // Cache the current children for the shrink animation. + this.setState({ outgoingChildren: children }); + } + } + + componentWillUpdate(nextProps: Props) { + const children = this.props.children as ComponentChild[]; + const nextChildren = nextProps.children as ComponentChild[]; + + // Only interested if going from empty to not-empty, or not-empty to empty. + if ((children[0] && nextChildren[0]) || (!children[0] && !nextChildren[0])) + return; + this.lastElHeight = (this + .base as HTMLElement).getBoundingClientRect().height; + } + + async componentDidUpdate(previousProps: Props) { + const children = this.props.children as ComponentChild[]; + const previousChildren = previousProps.children as ComponentChild[]; + + // Only interested if going from empty to not-empty, or not-empty to empty. + if ( + (children[0] && previousChildren[0]) || + (!children[0] && !previousChildren[0]) + ) + return; + + // What height do we need to transition to? + (this.base as HTMLElement).style.height = ''; + (this.base as HTMLElement).style.overflow = 'hidden'; + const newHeight = children[0] + ? (this.base as HTMLElement).getBoundingClientRect().height + : 0; + + await transitionHeight(this.base as HTMLElement, { + duration: 300, + from: this.lastElHeight, + to: newHeight, + }); + + // Unset the height & overflow, so element changes do the right thing. + (this.base as HTMLElement).style.height = ''; + (this.base as HTMLElement).style.overflow = ''; + if (this.state.outgoingChildren[0]) this.setState({ outgoingChildren: [] }); + } + + render(props: Props, { outgoingChildren }: State) { + const children = props.children as ComponentChild[]; + const childrenExiting = !children[0] && outgoingChildren[0]; + + return ( +
+ {children[0] ? children : outgoingChildren} +
+ ); + } +} diff --git a/src/client/lazy-app/Compress/Options/Expander/style.css b/src/client/lazy-app/Compress/Options/Expander/style.css new file mode 100644 index 00000000..5eeaeeb4 --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Expander/style.css @@ -0,0 +1,5 @@ +.children-exiting { + & > * { + pointer-events: none; + } +} diff --git a/src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/index.ts b/src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/index.ts new file mode 100644 index 00000000..c684e96b --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/index.ts @@ -0,0 +1,165 @@ +import PointerTracker from 'pointer-tracker'; +import * as style from './style.css'; +import 'add-css:./styles.css'; + +const RETARGETED_EVENTS = ['focus', 'blur']; +const UPDATE_EVENTS = ['input', 'change']; +const REFLECTED_PROPERTIES = [ + 'name', + 'min', + 'max', + 'step', + 'value', + 'disabled', +]; +const REFLECTED_ATTRIBUTES = [ + 'name', + 'min', + 'max', + 'step', + 'value', + 'disabled', +]; + +function getPrescision(value: string): number { + const afterDecimal = value.split('.')[1]; + return afterDecimal ? afterDecimal.length : 0; +} + +class RangeInputElement extends HTMLElement { + private _input: HTMLInputElement; + private _valueDisplay?: HTMLDivElement; + private _ignoreChange = false; + + static get observedAttributes() { + return REFLECTED_ATTRIBUTES; + } + + constructor() { + super(); + this._input = document.createElement('input'); + this._input.type = 'range'; + this._input.className = style.input; + + const tracker = new PointerTracker(this._input, { + start: (): boolean => { + if (tracker.currentPointers.length !== 0) return false; + this._input.classList.add(style.touchActive); + return true; + }, + end: () => { + this._input.classList.remove(style.touchActive); + }, + }); + + for (const event of RETARGETED_EVENTS) { + this._input.addEventListener(event, this._retargetEvent, true); + } + + for (const event of UPDATE_EVENTS) { + this._input.addEventListener(event, this._update, true); + } + } + + connectedCallback() { + if (this.contains(this._input)) return; + this.innerHTML = + `
` + + `
` + + `
` + + '
'; + + this.insertBefore(this._input, this.firstChild); + this._valueDisplay = this.querySelector( + '.' + style.valueDisplay, + ) as HTMLDivElement; + // Set inline styles (this is useful when used with frameworks which might clear inline styles) + this._update(); + } + + get labelPrecision(): string { + return this.getAttribute('label-precision') || ''; + } + + set labelPrecision(precision: string) { + this.setAttribute('label-precision', precision); + } + + attributeChangedCallback( + name: string, + oldValue: string, + newValue: string | null, + ) { + if (this._ignoreChange) return; + if (newValue === null) { + this._input.removeAttribute(name); + } else { + this._input.setAttribute(name, newValue); + } + this._reflectAttributes(); + this._update(); + } + + private _retargetEvent = (event: Event) => { + event.stopImmediatePropagation(); + const retargetted = new Event(event.type, event); + this.dispatchEvent(retargetted); + }; + + private _update = () => { + const value = Number(this.value) || 0; + const min = Number(this.min) || 0; + const max = Number(this.max) || 100; + const labelPrecision = + Number(this.labelPrecision) || getPrescision(this.step) || 0; + const percent = (100 * (value - min)) / (max - min); + const displayValue = labelPrecision + ? value.toFixed(labelPrecision) + : Math.round(value).toString(); + + this._valueDisplay!.textContent = displayValue; + this.style.setProperty('--value-percent', percent + '%'); + this.style.setProperty('--value-width', '' + displayValue.length); + }; + + private _reflectAttributes() { + this._ignoreChange = true; + for (const attributeName of REFLECTED_ATTRIBUTES) { + if (this._input.hasAttribute(attributeName)) { + this.setAttribute( + attributeName, + this._input.getAttribute(attributeName)!, + ); + } else { + this.removeAttribute(attributeName); + } + } + this._ignoreChange = false; + } +} + +interface RangeInputElement { + name: string; + min: string; + max: string; + step: string; + value: string; + disabled: boolean; +} + +for (const prop of REFLECTED_PROPERTIES) { + Object.defineProperty(RangeInputElement.prototype, prop, { + get() { + return this._input[prop]; + }, + set(val) { + this._input[prop] = val; + this._reflectAttributes(); + this._update(); + }, + }); +} + +export default RangeInputElement; + +customElements.define('range-input', RangeInputElement); diff --git a/src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/missing-types.d.ts b/src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/missing-types.d.ts new file mode 100644 index 00000000..7800e014 --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/missing-types.d.ts @@ -0,0 +1,9 @@ +declare module 'preact' { + namespace createElement.JSX { + interface IntrinsicElements { + 'range-input': HTMLAttributes; + } + } +} + +export {}; diff --git a/src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/styles.css b/src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/styles.css new file mode 100644 index 00000000..0b18ea33 --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Range/custom-els/RangeInput/styles.css @@ -0,0 +1,98 @@ +range-input { + position: relative; + display: flex; + height: 18px; + width: 130px; + margin: 2px; + font: inherit; + line-height: 16px; + overflow: visible; +} + +/* Disabled inputs are greyed out */ +range-input[disabled] { + filter: grayscale(1); +} + +range-input::before { + content: ''; + display: block; + position: absolute; + top: 8px; + left: 0; + width: 100%; + height: 2px; + border-radius: 1px; + box-shadow: 0 -0.5px 0 rgba(0, 0, 0, 0.3), + inset 0 0.5px 0 rgba(255, 255, 255, 0.2), 0 0.5px 0 rgba(255, 255, 255, 0.3); + background: linear-gradient(#34b9eb, #218ab1) 0 / var(--value-percent, 0%) + 100% no-repeat #eee; +} + +.input { + position: relative; + width: 100%; + padding: 0; + margin: 0; + opacity: 0; +} + +.thumb { + pointer-events: none; + position: absolute; + bottom: 3px; + left: var(--value-percent, 0%); + margin-left: -6px; + background: url('data:image/svg+xml,') + center no-repeat #34b9eb; + border-radius: 50%; + width: 12px; + height: 12px; + box-shadow: 0 0.5px 2px rgba(0, 0, 0, 0.3); +} + +.thumb-wrapper { + position: absolute; + left: 6px; + right: 6px; + bottom: 0; + height: 0; + overflow: visible; +} + +.value-display { + background: url('data:image/svg+xml,') + top center no-repeat; + position: absolute; + box-sizing: border-box; + left: var(--value-percent, 0%); + bottom: 3px; + width: 32px; + height: 62px; + text-align: center; + padding: 8px 3px 0; + margin: 0 0 0 -16px; + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.3)); + transform-origin: 50% 90%; + opacity: 0.0001; + transform: scale(0.2); + color: #fff; + font: inherit; + font-size: calc(100% - var(--value-width, 3) / 5 * 0.2em); + text-overflow: clip; + text-shadow: 0 -0.5px 0 rgba(0, 0, 0, 0.4); + transition: all 200ms ease; + transition-property: opacity, transform; + will-change: transform; + pointer-events: none; + overflow: hidden; +} + +.touch-active + .thumb-wrapper .value-display { + opacity: 1; + transform: scale(1); +} + +.touch-active + .thumb-wrapper .thumb { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); +} diff --git a/src/client/lazy-app/Compress/Options/Range/index.tsx b/src/client/lazy-app/Compress/Options/Range/index.tsx new file mode 100644 index 00000000..14633411 --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Range/index.tsx @@ -0,0 +1,55 @@ +import { h, Component } from 'preact'; +import * as style from './style.css'; +import 'add-css:./styles.css'; +import RangeInputElement from './custom-els/RangeInput'; +import '../../custom-els/RangeInput'; +import { linkRef } from 'shared/initial-app/util'; + +interface Props extends preact.JSX.HTMLAttributes {} +interface State {} + +export default class Range extends Component { + rangeWc?: RangeInputElement; + + private onTextInput = (event: Event) => { + const input = event.target as HTMLInputElement; + const value = input.value.trim(); + if (!value) return; + this.rangeWc!.value = input.value; + this.rangeWc!.dispatchEvent( + new InputEvent('input', { + bubbles: event.bubbles, + }), + ); + }; + + render(props: Props) { + const { children, ...otherProps } = props; + + const { value, min, max, step } = props; + + return ( + + ); + } +} diff --git a/src/client/lazy-app/Compress/Options/Range/style.css b/src/client/lazy-app/Compress/Options/Range/style.css new file mode 100644 index 00000000..ffe0d54e --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Range/style.css @@ -0,0 +1,55 @@ +.range { + position: relative; + z-index: 0; + display: grid; + grid-template-columns: 1fr auto; +} + +.label-text { + color: #fff; /* TEMP */ +} + +.range-wc-container { + position: relative; + z-index: 1; + grid-row: 2 / 3; + grid-column: 1 / 3; +} + +.range-wc { + width: 100%; +} + +.text-input { + grid-row: 1 / 2; + grid-column: 2 / 3; + + text-align: right; + background: transparent; + color: inherit; + font: inherit; + border: none; + padding: 2px 5px; + box-sizing: border-box; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-position: under; + width: 48px; + position: relative; + left: 5px; + + &:focus { + background: #fff; + color: #000; + } + + // Remove the number controls + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -moz-appearance: none; + -webkit-appearance: none; + margin: 0; + } +} diff --git a/src/client/lazy-app/Compress/Options/Select/index.tsx b/src/client/lazy-app/Compress/Options/Select/index.tsx new file mode 100644 index 00000000..46b28140 --- /dev/null +++ b/src/client/lazy-app/Compress/Options/Select/index.tsx @@ -0,0 +1,27 @@ +import { h, Component } from 'preact'; +import * as style from './style.css'; +import 'add-css:./styles.css'; + +interface Props extends preact.JSX.HTMLAttributes { + large?: boolean; +} +interface State {} + +export default class Select extends Component { + render(props: Props) { + const { large, ...otherProps } = props; + + return ( +
+ {/* @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019 */} + + + {Object.entries(supportedEncoderMap).map(([type, encoder]) => ( + + ))} + + ) : ( + + )} + + + + {EncoderOptionComponent && encoderState && ( + + )} + +
+ ); + } +} diff --git a/src/client/lazy-app/Compress/Options/style.css b/src/client/lazy-app/Compress/Options/style.css new file mode 100644 index 00000000..ac0efca4 --- /dev/null +++ b/src/client/lazy-app/Compress/Options/style.css @@ -0,0 +1,58 @@ +.options-scroller { + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + --horizontal-padding: 15px; +} + +.options-title { + background: rgba(0, 0, 0, 0.9); + margin: 0; + padding: 10px var(--horizontal-padding); + font-weight: normal; + font-size: 1.4rem; + border-bottom: 1px solid #000; +} + +.option-text-first { + display: grid; + align-items: center; + grid-template-columns: 87px 1fr; + grid-gap: 0.7em; + padding: 10px var(--horizontal-padding); +} + +.option-one-cell { + display: grid; + grid-template-columns: 1fr; + padding: 10px var(--horizontal-padding); +} + +.option-input-first, +.section-enabler { + cursor: pointer; + display: grid; + align-items: center; + grid-template-columns: auto 1fr; + grid-gap: 0.7em; + padding: 10px var(--horizontal-padding); +} + +.section-enabler { + background: rgba(0, 0, 0, 0.8); +} + +.options-section { + background: rgba(0, 0, 0, 0.7); +} + +.text-field { + background: #fff; + color: #000; + font: inherit; + border: none; + padding: 2px 0 2px 10px; + width: 100%; + box-sizing: border-box; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5); +} diff --git a/src/client/lazy-app/Compress/Output/style.css b/src/client/lazy-app/Compress/Output/style.css index 3434be1a..ab56f174 100644 --- a/src/client/lazy-app/Compress/Output/style.css +++ b/src/client/lazy-app/Compress/Output/style.css @@ -1,5 +1,5 @@ .output { - composes: abs-fill from '../../../../shared/initial-app/util.scss'; + composes: abs-fill from '../../../../shared/initial-app/util.css'; &::before { content: ''; @@ -19,12 +19,12 @@ } .two-up { - composes: abs-fill from '../../../../shared/initial-app/util.scss'; + composes: abs-fill from '../../../../shared/initial-app/util.css'; --accent-color: var(--button-fg); } .pinch-zoom { - composes: abs-fill from '../../../../shared/initial-app/util.scss'; + composes: abs-fill from '../../../../shared/initial-app/util.css'; outline: none; display: flex; justify-content: center; @@ -32,11 +32,11 @@ } .pinch-target { - // This fixes a severe painting bug in Chrome. - // We should try to remove this once the issue is fixed. - // https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10 + /* This fixes a severe painting bug in Chrome. + * We should try to remove this once the issue is fixed. + * https://bugs.chromium.org/p/chromium/issues/detail?id=870222#c10 */ will-change: auto; - // Prevent the image becoming misshapen due to default flexbox layout. + /* Prevent the image becoming misshapen due to default flexbox layout. */ flex-shrink: 0; } @@ -52,7 +52,7 @@ flex-wrap: wrap; contain: content; - // Allow clicks to fall through to the pinch zoom area + /* Allow clicks to fall through to the pinch zoom area */ pointer-events: none; & > * { pointer-events: auto; diff --git a/src/client/lazy-app/Compress/index.tsx b/src/client/lazy-app/Compress/index.tsx index c730d450..08dc703a 100644 --- a/src/client/lazy-app/Compress/index.tsx +++ b/src/client/lazy-app/Compress/index.tsx @@ -27,13 +27,14 @@ import Options from './Options'; import ResultCache from './result-cache'; import { cleanMerge, cleanSet } from '../util/clean-modify'; import './custom-els/MultiPanel'; +// TODO: you are here import Results from '../results'; -import { ExpandIcon, CopyAcrossIconProps } from '../../lib/icons'; -import SnackBarElement from '../../lib/SnackBar'; import WorkerBridge from '../worker-bridge'; import { resize } from 'features/processors/resize/client'; +import type SnackBarElement from 'shared/initial-app/custom-els/snack-bar'; +import { CopyAcrossIconProps, ExpandIcon } from '../icons'; -type OutputType = EncoderType | 'identity'; +export type OutputType = EncoderType | 'identity'; export interface SourceImage { file: File; @@ -761,7 +762,7 @@ export default class Compress extends Component { { this, index as 0 | 1, )} - onPreprocessorOptionsChange={this.onProcessorOptionsChange.bind( + onProcessorOptionsChange={this.onProcessorOptionsChange.bind( this, index as 0 | 1, )} diff --git a/src/features/README.md b/src/features/README.md index bc54455e..c59e38d1 100644 --- a/src/features/README.md +++ b/src/features/README.md @@ -41,9 +41,9 @@ Encoders must have the following: - `EncodeOptions` - An interface for the codec's options. - `defaultOptions` - An object of type `EncodeOptions`. -`client/index.ts` which exposes the following. +`client/index.ts` which exposes the following: -- `encode` - A method which takes args +- `encode` - A method which takes args: - `AbortSignal` - `WorkerBridge` - `ImageData` @@ -51,4 +51,15 @@ Encoders must have the following: And returns (a promise for) an `ArrayBuffer`. -Optionally it may include a method `featureTest`, which returns a boolean indicating support for this decoder. +Optionally it may export a method `featureTest`, which returns a boolean indicating support for this decoder. + +Optionally it may export a component, `Options`, with the following props: + +```ts +interface Props { + options: EncodeOptions; + onChange(newOptions: EncodeOptions): void; +} +``` + +…where `EncodeOptions` are the options for that encoder. diff --git a/src/features/client-utils/index.tsx b/src/features/client-utils/index.tsx new file mode 100644 index 00000000..24ddf80b --- /dev/null +++ b/src/features/client-utils/index.tsx @@ -0,0 +1,58 @@ +import { h, Component } from 'preact'; +import * as style from 'client/lazy-app/Compress/Options/style.css'; +import Range from 'client/lazy-app/Compress/Options/Range'; + +interface EncodeOptions { + quality: number; +} + +interface Props { + options: EncodeOptions; + onChange(newOptions: EncodeOptions): void; +} + +interface QualityOptionArg { + min?: number; + max?: number; + step?: number; +} + +type Constructor = new (...args: any[]) => T; + +// TypeScript requires an exported type for returned classes. This serves as the +// type for the class returned by `qualityOption`. +export interface QualityOptionsInterface extends Component {} + +export function qualityOption( + opts: QualityOptionArg = {}, +): Constructor { + const { min = 0, max = 100, step = 1 } = opts; + + class QualityOptions extends Component { + onChange = (event: Event) => { + const el = event.currentTarget as HTMLInputElement; + this.props.onChange({ quality: Number(el.value) }); + }; + + render({ options }: Props) { + return ( +
+
+ + Quality: + +
+
+ ); + } + } + + return QualityOptions; +} diff --git a/src/features/encoders/avif/client/index.ts b/src/features/encoders/avif/client/index.ts deleted file mode 100644 index 8318e4d6..00000000 --- a/src/features/encoders/avif/client/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { EncodeOptions } from '../shared/meta'; -import type WorkerBridge from 'client/lazy-app/worker-bridge'; - -export const encode = ( - signal: AbortSignal, - workerBridge: WorkerBridge, - imageData: ImageData, - options: EncodeOptions, -) => workerBridge.avifEncode(signal, imageData, options); diff --git a/src/features/encoders/avif/client/index.tsx b/src/features/encoders/avif/client/index.tsx new file mode 100644 index 00000000..cd0990f2 --- /dev/null +++ b/src/features/encoders/avif/client/index.tsx @@ -0,0 +1,361 @@ +import { EncodeOptions, defaultOptions } from '../shared/meta'; +import type WorkerBridge from 'client/lazy-app/worker-bridge'; +import { h, Component } from 'preact'; +import { preventDefault, shallowEqual } from 'client/lazy-app/util'; +import * as style from 'client/lazy-app/Compress/Options/style.css'; +import Checkbox from 'client/lazy-app/Compress/Options/Checkbox'; +import Expander from 'client/lazy-app/Compress/Options/Expander'; +import Select from 'client/lazy-app/Compress/Options/Select'; +import Range from 'client/lazy-app/Compress/Options/Range'; +import linkState from 'linkstate'; + +export const encode = ( + signal: AbortSignal, + workerBridge: WorkerBridge, + imageData: ImageData, + options: EncodeOptions, +) => workerBridge.avifEncode(signal, imageData, options); + +interface Props { + options: EncodeOptions; + onChange(newOptions: EncodeOptions): void; +} + +interface State { + options: EncodeOptions; + lossless: boolean; + maxQuality: number; + minQuality: number; + separateAlpha: boolean; + losslessAlpha: boolean; + maxAlphaQuality: number; + minAlphaQuality: number; + showAdvanced: boolean; + grayscale: boolean; + subsample: number; + tileRows: number; + tileCols: number; + effort: number; +} + +const maxQuant = 63; +const maxSpeed = 10; + +export class Options extends Component { + static getDerivedStateFromProps( + props: Props, + state: State, + ): Partial | null { + if (state.options && shallowEqual(state.options, props.options)) { + return null; + } + + const { options } = props; + + const lossless = options.maxQuantizer === 0 && options.minQuantizer === 0; + const minQuantizerValue = lossless + ? defaultOptions.minQuantizer + : options.minQuantizer; + const maxQuantizerValue = lossless + ? defaultOptions.maxQuantizer + : options.maxQuantizer; + const losslessAlpha = + options.maxQuantizerAlpha === 0 && options.minQuantizerAlpha === 0; + const minQuantizerAlphaValue = losslessAlpha + ? defaultOptions.minQuantizerAlpha + : options.minQuantizerAlpha; + const maxQuantizerAlphaValue = losslessAlpha + ? defaultOptions.maxQuantizerAlpha + : options.maxQuantizerAlpha; + + // Create default form state from options + return { + options, + lossless, + losslessAlpha, + maxQuality: maxQuant - minQuantizerValue, + minQuality: maxQuant - maxQuantizerValue, + separateAlpha: + options.maxQuantizer !== options.maxQuantizerAlpha || + options.minQuantizer !== options.minQuantizerAlpha, + maxAlphaQuality: maxQuant - minQuantizerAlphaValue, + minAlphaQuality: maxQuant - maxQuantizerAlphaValue, + grayscale: options.subsample === 0, + subsample: + options.subsample === 0 || lossless + ? defaultOptions.subsample + : options.subsample, + tileRows: options.tileRowsLog2, + tileCols: options.tileColsLog2, + effort: maxSpeed - options.speed, + }; + } + + // The rest of the defaults are set in getDerivedStateFromProps + state: State = { + showAdvanced: false, + } as State; + + private _inputChangeCallbacks = new Map void>(); + + private _inputChange = (prop: keyof State, type: 'number' | 'boolean') => { + // Cache the callback for performance + if (!this._inputChangeCallbacks.has(prop)) { + this._inputChangeCallbacks.set(prop, (event: Event) => { + const formEl = event.target as HTMLInputElement | HTMLSelectElement; + const newVal = + type === 'boolean' + ? 'checked' in formEl + ? formEl.checked + : !!formEl.value + : Number(formEl.value); + + const newState: Partial = { + [prop]: newVal, + }; + + // Ensure that min cannot be greater than max + switch (prop) { + case 'maxQuality': + if (newVal < this.state.minQuality) { + newState.minQuality = newVal as number; + } + break; + case 'minQuality': + if (newVal > this.state.maxQuality) { + newState.maxQuality = newVal as number; + } + break; + case 'maxAlphaQuality': + if (newVal < this.state.minAlphaQuality) { + newState.minAlphaQuality = newVal as number; + } + break; + case 'minAlphaQuality': + if (newVal > this.state.maxAlphaQuality) { + newState.maxAlphaQuality = newVal as number; + } + break; + } + + const optionState = { + ...this.state, + ...newState, + }; + + const maxQuantizer = optionState.lossless + ? 0 + : maxQuant - optionState.minQuality; + const minQuantizer = optionState.lossless + ? 0 + : maxQuant - optionState.maxQuality; + + const newOptions: EncodeOptions = { + maxQuantizer, + minQuantizer, + maxQuantizerAlpha: optionState.separateAlpha + ? optionState.losslessAlpha + ? 0 + : maxQuant - optionState.minAlphaQuality + : maxQuantizer, + minQuantizerAlpha: optionState.separateAlpha + ? optionState.losslessAlpha + ? 0 + : maxQuant - optionState.maxAlphaQuality + : minQuantizer, + // Always set to 4:4:4 if lossless + subsample: optionState.grayscale + ? 0 + : optionState.lossless + ? 3 + : optionState.subsample, + tileColsLog2: optionState.tileCols, + tileRowsLog2: optionState.tileRows, + speed: maxSpeed - optionState.effort, + }; + + // Updating options, so we don't recalculate in getDerivedStateFromProps. + newState.options = newOptions; + + this.setState( + // It isn't clear to me why I have to cast this :) + newState as State, + ); + + this.props.onChange(newOptions); + }); + } + + return this._inputChangeCallbacks.get(prop)!; + }; + + render( + _: Props, + { + effort, + grayscale, + lossless, + losslessAlpha, + maxAlphaQuality, + maxQuality, + minAlphaQuality, + minQuality, + separateAlpha, + showAdvanced, + subsample, + tileCols, + tileRows, + }: State, + ) { + return ( +
+ + + {!lossless && ( +
+
+ + Max quality: + +
+
+ + Min quality: + +
+
+ )} +
+ + + {separateAlpha && ( +
+ + + {!losslessAlpha && ( +
+
+ + Max alpha quality: + +
+
+ + Min alpha quality: + +
+
+ )} +
+
+ )} +
+ + + {showAdvanced && ( +
+ {/**/} + + {!grayscale && !lossless && ( + + )} + +
+ + Log2 of tile rows: + +
+
+ + Log2 of tile cols: + +
+
+ )} +
+
+ + Effort: + +
+
+ ); + } +} diff --git a/src/features/encoders/avif/client/missing-types.d.ts b/src/features/encoders/avif/client/missing-types.d.ts deleted file mode 100644 index c729fd74..00000000 --- a/src/features/encoders/avif/client/missing-types.d.ts +++ /dev/null @@ -1,13 +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. - */ -/// diff --git a/src/features/encoders/browserJPEG/client/index.ts b/src/features/encoders/browserJPEG/client/index.ts index a439b7e3..8837c93f 100644 --- a/src/features/encoders/browserJPEG/client/index.ts +++ b/src/features/encoders/browserJPEG/client/index.ts @@ -1,5 +1,6 @@ import { canvasEncode } from 'client/lazy-app/util'; import WorkerBridge from 'client/lazy-app/worker-bridge'; +import { qualityOption } from 'features/client-utils'; import { mimeType, EncodeOptions } from '../shared/meta'; export const encode = ( @@ -8,3 +9,5 @@ export const encode = ( imageData: ImageData, options: EncodeOptions, ) => canvasEncode(imageData, mimeType, options.quality); + +export const Options = qualityOption({ min: 0, max: 1, step: 0.01 }); diff --git a/src/features/encoders/mozjpeg/client/index.ts b/src/features/encoders/mozjpeg/client/index.ts deleted file mode 100644 index 5f0bcf93..00000000 --- a/src/features/encoders/mozjpeg/client/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { EncodeOptions } from '../shared/meta'; -import type WorkerBridge from 'client/lazy-app/worker-bridge'; - -export function encode( - signal: AbortSignal, - workerBridge: WorkerBridge, - imageData: ImageData, - options: EncodeOptions, -) { - return workerBridge.mozjpegEncode(signal, imageData, options); -} diff --git a/src/features/encoders/mozjpeg/client/index.tsx b/src/features/encoders/mozjpeg/client/index.tsx new file mode 100644 index 00000000..ab20af76 --- /dev/null +++ b/src/features/encoders/mozjpeg/client/index.tsx @@ -0,0 +1,299 @@ +import { EncodeOptions, MozJpegColorSpace } from '../shared/meta'; +import type WorkerBridge from 'client/lazy-app/worker-bridge'; +import { h, Component } from 'preact'; +import { + inputFieldChecked, + inputFieldValueAsNumber, + preventDefault, +} from 'client/lazy-app/util'; +import * as style from 'client/lazy-app/Compress/Options/style.css'; +import linkState from 'linkstate'; +import Range from 'client/lazy-app/Compress/Options/Range'; +import Checkbox from 'client/lazy-app/Compress/Options/Checkbox'; +import Expander from 'client/lazy-app/Compress/Options/Expander'; +import Select from 'client/lazy-app/Compress/Options/Select'; + +export function encode( + signal: AbortSignal, + workerBridge: WorkerBridge, + imageData: ImageData, + options: EncodeOptions, +) { + return workerBridge.mozjpegEncode(signal, imageData, options); +} + +interface Props { + options: EncodeOptions; + onChange(newOptions: EncodeOptions): void; +} + +interface State { + showAdvanced: boolean; +} + +export class Options extends Component { + state: State = { + showAdvanced: false, + }; + + onChange = (event: Event) => { + const form = (event.currentTarget as HTMLInputElement).closest( + 'form', + ) as HTMLFormElement; + const { options } = this.props; + + const newOptions: EncodeOptions = { + // Copy over options the form doesn't currently care about, eg arithmetic + ...this.props.options, + // And now stuff from the form: + // .checked + baseline: inputFieldChecked(form.baseline, options.baseline), + progressive: inputFieldChecked(form.progressive, options.progressive), + optimize_coding: inputFieldChecked( + form.optimize_coding, + options.optimize_coding, + ), + trellis_multipass: inputFieldChecked( + form.trellis_multipass, + options.trellis_multipass, + ), + trellis_opt_zero: inputFieldChecked( + form.trellis_opt_zero, + options.trellis_opt_zero, + ), + trellis_opt_table: inputFieldChecked( + form.trellis_opt_table, + options.trellis_opt_table, + ), + auto_subsample: inputFieldChecked( + form.auto_subsample, + options.auto_subsample, + ), + separate_chroma_quality: inputFieldChecked( + form.separate_chroma_quality, + options.separate_chroma_quality, + ), + // .value + quality: inputFieldValueAsNumber(form.quality, options.quality), + chroma_quality: inputFieldValueAsNumber( + form.chroma_quality, + options.chroma_quality, + ), + chroma_subsample: inputFieldValueAsNumber( + form.chroma_subsample, + options.chroma_subsample, + ), + smoothing: inputFieldValueAsNumber(form.smoothing, options.smoothing), + color_space: inputFieldValueAsNumber( + form.color_space, + options.color_space, + ), + quant_table: inputFieldValueAsNumber( + form.quant_table, + options.quant_table, + ), + trellis_loops: inputFieldValueAsNumber( + form.trellis_loops, + options.trellis_loops, + ), + }; + this.props.onChange(newOptions); + }; + + render({ options }: Props, { showAdvanced }: State) { + // I'm rendering both lossy and lossless forms, as it becomes much easier when + // gathering the data. + return ( +
+
+ + Quality: + +
+ + + {showAdvanced ? ( +
+ + + {options.color_space === MozJpegColorSpace.YCbCr ? ( +
+ + + {options.auto_subsample ? null : ( +
+ + Subsample chroma by: + +
+ )} +
+ + + {options.separate_chroma_quality ? ( +
+ + Chroma quality: + +
+ ) : null} +
+
+ ) : null} +
+ + + {options.baseline ? null : ( + + )} + + + {options.baseline ? ( + + ) : null} + +
+ + Smoothing: + +
+ + + + {options.trellis_multipass ? ( + + ) : null} + + +
+ + Trellis quantization passes: + +
+
+ ) : null} +
+
+ ); + } +} diff --git a/src/features/encoders/oxipng/client/index.ts b/src/features/encoders/oxipng/client/index.ts deleted file mode 100644 index cbae1974..00000000 --- a/src/features/encoders/oxipng/client/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - canvasEncode, - abortable, - blobToArrayBuffer, -} from 'client/lazy-app/util'; -import { EncodeOptions } from '../shared/meta'; -import type WorkerBridge from 'client/lazy-app/worker-bridge'; - -export async function encode( - signal: AbortSignal, - workerBridge: WorkerBridge, - imageData: ImageData, - options: EncodeOptions, -) { - const pngBlob = await abortable(signal, canvasEncode(imageData, 'image/png')); - const pngBuffer = await abortable(signal, blobToArrayBuffer(pngBlob)); - return workerBridge.oxipngEncode(signal, pngBuffer, options); -} diff --git a/src/features/encoders/oxipng/client/index.tsx b/src/features/encoders/oxipng/client/index.tsx new file mode 100644 index 00000000..155b3b0c --- /dev/null +++ b/src/features/encoders/oxipng/client/index.tsx @@ -0,0 +1,59 @@ +import { + canvasEncode, + abortable, + blobToArrayBuffer, +} from 'client/lazy-app/util'; +import { EncodeOptions } from '../shared/meta'; +import type WorkerBridge from 'client/lazy-app/worker-bridge'; +import { h, Component } from 'preact'; +import { inputFieldValueAsNumber, preventDefault } from 'client/lazy-app/util'; +import * as style from 'client/lazy-app/Compress/Options/style.css'; +import Range from 'client/lazy-app/Compress/Options/Range'; + +export async function encode( + signal: AbortSignal, + workerBridge: WorkerBridge, + imageData: ImageData, + options: EncodeOptions, +) { + const pngBlob = await abortable(signal, canvasEncode(imageData, 'image/png')); + const pngBuffer = await abortable(signal, blobToArrayBuffer(pngBlob)); + return workerBridge.oxipngEncode(signal, pngBuffer, options); +} + +type Props = { + options: EncodeOptions; + onChange(newOptions: EncodeOptions): void; +}; + +export class Options extends Component { + onChange = (event: Event) => { + const form = (event.currentTarget as HTMLInputElement).closest( + 'form', + ) as HTMLFormElement; + + const options: EncodeOptions = { + level: inputFieldValueAsNumber(form.level), + }; + this.props.onChange(options); + }; + + render({ options }: Props) { + return ( +
+
+ + Effort: + +
+
+ ); + } +} diff --git a/src/features/encoders/webp/client/index.ts b/src/features/encoders/webp/client/index.ts deleted file mode 100644 index f49f5625..00000000 --- a/src/features/encoders/webp/client/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { EncodeOptions } from '../shared/meta'; -import type WorkerBridge from 'client/lazy-app/worker-bridge'; - -export const encode = ( - signal: AbortSignal, - workerBridge: WorkerBridge, - imageData: ImageData, - options: EncodeOptions, -) => workerBridge.webpEncode(signal, imageData, options); diff --git a/src/features/encoders/webp/client/index.tsx b/src/features/encoders/webp/client/index.tsx new file mode 100644 index 00000000..f324df07 --- /dev/null +++ b/src/features/encoders/webp/client/index.tsx @@ -0,0 +1,407 @@ +import { EncodeOptions } from '../shared/meta'; +import type WorkerBridge from 'client/lazy-app/worker-bridge'; +import { h, Component } from 'preact'; +import { + inputFieldCheckedAsNumber, + inputFieldValueAsNumber, + preventDefault, +} from 'client/lazy-app/util'; +import * as style from 'client/lazy-app/Compress/Options/style.css'; +import linkState from 'linkstate'; +import Range from 'client/lazy-app/Compress/Options/Range'; +import Checkbox from 'client/lazy-app/Compress/Options/Checkbox'; +import Expander from 'client/lazy-app/Compress/Options/Expander'; +import Select from 'client/lazy-app/Compress/Options/Select'; + +export const encode = ( + signal: AbortSignal, + workerBridge: WorkerBridge, + imageData: ImageData, + options: EncodeOptions, +) => workerBridge.webpEncode(signal, imageData, options); + +const enum WebPImageHint { + WEBP_HINT_DEFAULT, // default preset. + WEBP_HINT_PICTURE, // digital picture, like portrait, inner shot + WEBP_HINT_PHOTO, // outdoor photograph, with natural lighting + WEBP_HINT_GRAPH, // Discrete tone image (graph, map-tile etc). +} + +interface Props { + options: EncodeOptions; + onChange(newOptions: EncodeOptions): void; +} + +interface State { + showAdvanced: boolean; +} + +// From kLosslessPresets in config_enc.c +// The format is [method, quality]. +const losslessPresets: [number, number][] = [ + [0, 0], + [1, 20], + [2, 25], + [3, 30], + [3, 50], + [4, 50], + [4, 75], + [4, 90], + [5, 90], + [6, 100], +]; +const losslessPresetDefault = 6; + +function determineLosslessQuality(quality: number, method: number): number { + const index = losslessPresets.findIndex( + ([presetMethod, presetQuality]) => + presetMethod === method && presetQuality === quality, + ); + if (index !== -1) return index; + // Quality doesn't match one of the presets. + // This can happen when toggling 'lossless'. + return losslessPresetDefault; +} + +export class Options extends Component { + state: State = { + showAdvanced: false, + }; + + onChange = (event: Event) => { + const form = (event.currentTarget as HTMLInputElement).closest( + 'form', + ) as HTMLFormElement; + const lossless = inputFieldCheckedAsNumber(form.lossless); + const { options } = this.props; + const losslessPresetValue = inputFieldValueAsNumber( + form.lossless_preset, + determineLosslessQuality(options.quality, options.method), + ); + + const newOptions: EncodeOptions = { + // Copy over options the form doesn't care about, eg emulate_jpeg_size + ...options, + // And now stuff from the form: + lossless, + // Special-cased inputs: + // In lossless mode, the quality is derived from the preset. + quality: lossless + ? losslessPresets[losslessPresetValue][1] + : inputFieldValueAsNumber(form.quality, options.quality), + // In lossless mode, the method is derived from the preset. + method: lossless + ? losslessPresets[losslessPresetValue][0] + : inputFieldValueAsNumber(form.method_input, options.method), + image_hint: inputFieldCheckedAsNumber(form.image_hint, options.image_hint) + ? WebPImageHint.WEBP_HINT_GRAPH + : WebPImageHint.WEBP_HINT_DEFAULT, + // .checked + exact: inputFieldCheckedAsNumber(form.exact, options.exact), + alpha_compression: inputFieldCheckedAsNumber( + form.alpha_compression, + options.alpha_compression, + ), + autofilter: inputFieldCheckedAsNumber( + form.autofilter, + options.autofilter, + ), + filter_type: inputFieldCheckedAsNumber( + form.filter_type, + options.filter_type, + ), + use_sharp_yuv: inputFieldCheckedAsNumber( + form.use_sharp_yuv, + options.use_sharp_yuv, + ), + // .value + near_lossless: + 100 - + inputFieldValueAsNumber( + form.near_lossless, + 100 - options.near_lossless, + ), + alpha_quality: inputFieldValueAsNumber( + form.alpha_quality, + options.alpha_quality, + ), + alpha_filtering: inputFieldValueAsNumber( + form.alpha_filtering, + options.alpha_filtering, + ), + sns_strength: inputFieldValueAsNumber( + form.sns_strength, + options.sns_strength, + ), + filter_strength: inputFieldValueAsNumber( + form.filter_strength, + options.filter_strength, + ), + filter_sharpness: + 7 - + inputFieldValueAsNumber( + form.filter_sharpness, + 7 - options.filter_sharpness, + ), + pass: inputFieldValueAsNumber(form.pass, options.pass), + preprocessing: inputFieldValueAsNumber( + form.preprocessing, + options.preprocessing, + ), + segments: inputFieldValueAsNumber(form.segments, options.segments), + partitions: inputFieldValueAsNumber(form.partitions, options.partitions), + }; + this.props.onChange(newOptions); + }; + + private _losslessSpecificOptions(options: EncodeOptions) { + return ( +
+
+ + Effort: + +
+
+ + Slight loss: + +
+ +
+ ); + } + + private _lossySpecificOptions(options: EncodeOptions) { + const { showAdvanced } = this.state; + + return ( +
+
+ + Effort: + +
+
+ + Quality: + +
+ + + {showAdvanced ? ( +
+ +
+ + Alpha quality: + +
+
+ + Alpha filter quality: + +
+ + + {options.autofilter ? null : ( +
+ + Filter strength: + +
+ )} +
+ +
+ + Filter sharpness: + +
+ +
+ + Passes: + +
+
+ + Spatial noise shaping: + +
+ +
+ + Segments: + +
+
+ + Partitions: + +
+
+ ) : null} +
+
+ ); + } + + render({ options }: Props) { + // I'm rendering both lossy and lossless forms, as it becomes much easier when + // gathering the data. + return ( +
+ + {options.lossless + ? this._losslessSpecificOptions(options) + : this._lossySpecificOptions(options)} + +
+ ); + } +} diff --git a/src/features/processors/quantize/client/index.tsx b/src/features/processors/quantize/client/index.tsx new file mode 100644 index 00000000..e131fdb4 --- /dev/null +++ b/src/features/processors/quantize/client/index.tsx @@ -0,0 +1,98 @@ +import { h, Component } from 'preact'; +import { Options as QuantizeOptions } from '../shared/meta'; +import * as style from 'client/lazy-app/Compress/Options/style.css'; +import { + inputFieldValueAsNumber, + konami, + preventDefault, +} from 'client/lazy-app/util'; +import Expander from 'client/lazy-app/Compress/Options/Expander'; +import Select from 'client/lazy-app/Compress/Options/Select'; +import Range from 'client/lazy-app/Compress/Options/Range'; + +const konamiPromise = konami(); + +interface Props { + options: QuantizeOptions; + onChange(newOptions: QuantizeOptions): void; +} + +interface State { + extendedSettings: boolean; +} + +export class Options extends Component { + state: State = { extendedSettings: false }; + + componentDidMount() { + konamiPromise.then(() => { + this.setState({ extendedSettings: true }); + }); + } + + onChange = (event: Event) => { + const form = (event.currentTarget as HTMLInputElement).closest( + 'form', + ) as HTMLFormElement; + const { options } = this.props; + + const newOptions: QuantizeOptions = { + zx: inputFieldValueAsNumber(form.zx, options.zx), + maxNumColors: inputFieldValueAsNumber( + form.maxNumColors, + options.maxNumColors, + ), + dither: inputFieldValueAsNumber(form.dither), + }; + this.props.onChange(newOptions); + }; + + render({ options }: Props, { extendedSettings }: State) { + return ( +
+ + {extendedSettings ? ( + + ) : null} + + + {options.zx ? null : ( +
+ + Colors: + +
+ )} +
+
+ + Dithering: + +
+
+ ); + } +} diff --git a/src/features/processors/resize/client/index.ts b/src/features/processors/resize/client/index.ts deleted file mode 100644 index 49a21726..00000000 --- a/src/features/processors/resize/client/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - builtinResize, - BuiltinResizeMethod, - drawableToImageData, -} from 'client/lazy-app/util'; -import { - BrowserResizeOptions, - VectorResizeOptions, - WorkerResizeOptions, - Options, - workerResizeMethods, -} from '../shared/meta'; -import { getContainOffsets } from '../shared/util'; -import type { SourceImage } from 'client/lazy-app/Compress'; -import type WorkerBridge from 'client/lazy-app/worker-bridge'; - -/** - * Return whether a set of options are worker resize options. - * - * @param opts - */ -function isWorkerOptions(opts: Options): opts is WorkerResizeOptions { - return (workerResizeMethods as string[]).includes(opts.method); -} - -function browserResize(data: ImageData, opts: BrowserResizeOptions): ImageData { - let sx = 0; - let sy = 0; - let sw = data.width; - let sh = data.height; - - if (opts.fitMethod === 'contain') { - ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); - } - - return builtinResize( - data, - sx, - sy, - sw, - sh, - opts.width, - opts.height, - opts.method.slice('browser-'.length) as BuiltinResizeMethod, - ); -} - -function vectorResize( - data: HTMLImageElement, - opts: VectorResizeOptions, -): ImageData { - let sx = 0; - let sy = 0; - let sw = data.width; - let sh = data.height; - - if (opts.fitMethod === 'contain') { - ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); - } - - return drawableToImageData(data, { - sx, - sy, - sw, - sh, - width: opts.width, - height: opts.height, - }); -} - -export async function resize( - signal: AbortSignal, - source: SourceImage, - options: Options, - workerBridge: WorkerBridge, -) { - if (options.method === 'vector') { - if (!source.vectorImage) throw Error('No vector image available'); - return vectorResize(source.vectorImage, options); - } - if (isWorkerOptions(options)) { - return workerBridge.resize(signal, source.preprocessed, options); - } - return browserResize(source.preprocessed, options); -} diff --git a/src/features/processors/resize/client/index.tsx b/src/features/processors/resize/client/index.tsx new file mode 100644 index 00000000..5f048a8b --- /dev/null +++ b/src/features/processors/resize/client/index.tsx @@ -0,0 +1,334 @@ +import { + builtinResize, + BuiltinResizeMethod, + drawableToImageData, +} from 'client/lazy-app/util'; +import { + BrowserResizeOptions, + VectorResizeOptions, + WorkerResizeOptions, + Options as ResizeOptions, + workerResizeMethods, +} from '../shared/meta'; +import { getContainOffsets } from '../shared/util'; +import type { SourceImage } from 'client/lazy-app/Compress'; +import type WorkerBridge from 'client/lazy-app/worker-bridge'; +import { h, Component } from 'preact'; +import linkState from 'linkstate'; +import { + inputFieldValueAsNumber, + inputFieldValue, + preventDefault, + inputFieldChecked, +} from 'client/lazy-app/util'; +import * as style from 'client/lazy-app/Compress/Options/style.css'; +import { linkRef } from 'shared/initial-app/util'; +import Select from 'client/lazy-app/Compress/Options/Select'; +import Expander from 'client/lazy-app/Compress/Options/Expander'; +import Checkbox from 'client/lazy-app/Compress/Options/Checkbox'; + +/** + * Return whether a set of options are worker resize options. + * + * @param opts + */ +function isWorkerOptions(opts: ResizeOptions): opts is WorkerResizeOptions { + return (workerResizeMethods as string[]).includes(opts.method); +} + +function browserResize(data: ImageData, opts: BrowserResizeOptions): ImageData { + let sx = 0; + let sy = 0; + let sw = data.width; + let sh = data.height; + + if (opts.fitMethod === 'contain') { + ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); + } + + return builtinResize( + data, + sx, + sy, + sw, + sh, + opts.width, + opts.height, + opts.method.slice('browser-'.length) as BuiltinResizeMethod, + ); +} + +function vectorResize( + data: HTMLImageElement, + opts: VectorResizeOptions, +): ImageData { + let sx = 0; + let sy = 0; + let sw = data.width; + let sh = data.height; + + if (opts.fitMethod === 'contain') { + ({ sx, sy, sw, sh } = getContainOffsets(sw, sh, opts.width, opts.height)); + } + + return drawableToImageData(data, { + sx, + sy, + sw, + sh, + width: opts.width, + height: opts.height, + }); +} + +export async function resize( + signal: AbortSignal, + source: SourceImage, + options: ResizeOptions, + workerBridge: WorkerBridge, +) { + if (options.method === 'vector') { + if (!source.vectorImage) throw Error('No vector image available'); + return vectorResize(source.vectorImage, options); + } + if (isWorkerOptions(options)) { + return workerBridge.resize(signal, source.preprocessed, options); + } + return browserResize(source.preprocessed, options); +} + +interface Props { + isVector: Boolean; + inputWidth: number; + inputHeight: number; + options: ResizeOptions; + onChange(newOptions: ResizeOptions): void; +} + +interface State { + maintainAspect: boolean; +} + +const sizePresets = [0.25, 0.3333, 0.5, 1, 2, 3, 4]; + +export class Options extends Component { + state: State = { + maintainAspect: true, + }; + + private form?: HTMLFormElement; + private presetWidths: { [idx: number]: number } = {}; + private presetHeights: { [idx: number]: number } = {}; + + constructor(props: Props) { + super(props); + this.generatePresetValues(props.inputWidth, props.inputHeight); + } + + private reportOptions() { + const form = this.form!; + const width = form.width as HTMLInputElement; + const height = form.height as HTMLInputElement; + const { options } = this.props; + + if (!width.checkValidity() || !height.checkValidity()) return; + + const newOptions: ResizeOptions = { + width: inputFieldValueAsNumber(width), + height: inputFieldValueAsNumber(height), + method: form.resizeMethod.value, + premultiply: inputFieldChecked(form.premultiply, true), + linearRGB: inputFieldChecked(form.linearRGB, true), + // Casting, as the formfield only returns the correct values. + fitMethod: inputFieldValue( + form.fitMethod, + options.fitMethod, + ) as ResizeOptions['fitMethod'], + }; + this.props.onChange(newOptions); + } + + private onChange = () => { + this.reportOptions(); + }; + + private getAspect() { + return this.props.inputWidth / this.props.inputHeight; + } + + componentDidUpdate(prevProps: Props, prevState: State) { + if (!prevState.maintainAspect && this.state.maintainAspect) { + this.form!.height.value = Math.round( + Number(this.form!.width.value) / this.getAspect(), + ); + this.reportOptions(); + } + } + + componentWillReceiveProps(nextProps: Props) { + if ( + this.props.inputWidth !== nextProps.inputWidth || + this.props.inputHeight !== nextProps.inputHeight + ) { + this.generatePresetValues(nextProps.inputWidth, nextProps.inputHeight); + } + } + + private onWidthInput = () => { + if (this.state.maintainAspect) { + const width = inputFieldValueAsNumber(this.form!.width); + this.form!.height.value = Math.round(width / this.getAspect()); + } + + this.reportOptions(); + }; + + private onHeightInput = () => { + if (this.state.maintainAspect) { + const height = inputFieldValueAsNumber(this.form!.height); + this.form!.width.value = Math.round(height * this.getAspect()); + } + + this.reportOptions(); + }; + + private generatePresetValues(width: number, height: number) { + for (const preset of sizePresets) { + this.presetWidths[preset] = Math.round(width * preset); + this.presetHeights[preset] = Math.round(height * preset); + } + } + + private getPreset(): number | string { + const { width, height } = this.props.options; + + for (const preset of sizePresets) { + if ( + width === this.presetWidths[preset] && + height === this.presetHeights[preset] + ) + return preset; + } + + return 'custom'; + } + + private onPresetChange = (event: Event) => { + const select = event.target as HTMLSelectElement; + if (select.value === 'custom') return; + const multiplier = Number(select.value); + (this.form!.width as HTMLInputElement).value = + Math.round(this.props.inputWidth * multiplier) + ''; + (this.form!.height as HTMLInputElement).value = + Math.round(this.props.inputHeight * multiplier) + ''; + this.reportOptions(); + }; + + render({ options, isVector }: Props, { maintainAspect }: State) { + return ( +
+ + + + + + {isWorkerOptions(options) ? ( + + ) : null} + {isWorkerOptions(options) ? ( + + ) : null} + + + + {maintainAspect ? null : ( + + )} + +
+ ); + } +}