diff --git a/package-lock.json b/package-lock.json index 40593ac5..38d95538 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2782,6 +2782,12 @@ "semver-compare": "^1.0.0" } }, + "pointer-tracker": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/pointer-tracker/-/pointer-tracker-2.4.0.tgz", + "integrity": "sha512-pWI2tpaM/XNtc9mUTv42Rmjf6mkHvE8LT5DDEq0G7baPNhxNM9E3CepubPplSoSLk9E5bwQrAMyDcPVmJyTW4g==", + "dev": true + }, "postcss": { "version": "7.0.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz", diff --git a/package.json b/package.json index c3096abf..776463b9 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "lint-staged": "^10.4.0", "lodash.camelcase": "^4.3.0", "mime-types": "^2.1.27", + "pointer-tracker": "^2.4.0", "postcss": "^7.0.35", "postcss-modules": "^3.2.2", "postcss-nested": "^4.2.3", diff --git a/src/client/lazy-app/Compress/Output/custom-els/PinchZoom/index.ts b/src/client/lazy-app/Compress/Output/custom-els/PinchZoom/index.ts new file mode 100644 index 00000000..a14514d3 --- /dev/null +++ b/src/client/lazy-app/Compress/Output/custom-els/PinchZoom/index.ts @@ -0,0 +1,376 @@ +import PointerTracker, { Pointer } from 'pointer-tracker'; +import 'add-css:./styles.css'; + +interface Point { + clientX: number; + clientY: number; +} + +interface ChangeOptions { + /** + * Fire a 'change' event if values are different to current values + */ + allowChangeEvent?: boolean; +} + +interface ApplyChangeOpts extends ChangeOptions { + panX?: number; + panY?: number; + scaleDiff?: number; + originX?: number; + originY?: number; +} + +interface SetTransformOpts extends ChangeOptions { + scale?: number; + x?: number; + y?: number; +} + +type ScaleRelativeToValues = 'container' | 'content'; + +export interface ScaleToOpts extends ChangeOptions { + /** Transform origin. Can be a number, or string percent, eg "50%" */ + originX?: number | string; + /** Transform origin. Can be a number, or string percent, eg "50%" */ + originY?: number | string; + /** Should the transform origin be relative to the container, or content? */ + relativeTo?: ScaleRelativeToValues; +} + +function getDistance(a: Point, b?: Point): number { + if (!b) return 0; + return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2); +} + +function getMidpoint(a: Point, b?: Point): Point { + if (!b) return a; + + return { + clientX: (a.clientX + b.clientX) / 2, + clientY: (a.clientY + b.clientY) / 2, + }; +} + +function getAbsoluteValue(value: string | number, max: number): number { + if (typeof value === 'number') return value; + + if (value.trimRight().endsWith('%')) { + return (max * parseFloat(value)) / 100; + } + return parseFloat(value); +} + +// I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough. +// Given that, better to use something everything supports. +let cachedSvg: SVGSVGElement; + +function getSVG(): SVGSVGElement { + return ( + cachedSvg || + (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')) + ); +} + +function createMatrix(): SVGMatrix { + return getSVG().createSVGMatrix(); +} + +function createPoint(): SVGPoint { + return getSVG().createSVGPoint(); +} + +const MIN_SCALE = 0.01; + +export default class PinchZoom extends HTMLElement { + // The element that we'll transform. + // Ideally this would be shadow DOM, but we don't have the browser + // support yet. + private _positioningEl?: Element; + // Current transform. + private _transform: SVGMatrix = createMatrix(); + + constructor() { + super(); + + // Watch for children changes. + // Note this won't fire for initial contents, + // so _stageElChange is also called in connectedCallback. + new MutationObserver(() => this._stageElChange()).observe(this, { + childList: true, + }); + + // Watch for pointers + const pointerTracker: PointerTracker = new PointerTracker(this, { + start: (pointer, event) => { + // We only want to track 2 pointers at most + if (pointerTracker.currentPointers.length === 2 || !this._positioningEl) + return false; + event.preventDefault(); + return true; + }, + move: (previousPointers) => { + this._onPointerMove(previousPointers, pointerTracker.currentPointers); + }, + }); + + this.addEventListener('wheel', (event) => this._onWheel(event)); + } + + connectedCallback() { + this._stageElChange(); + } + + get x() { + return this._transform.e; + } + + get y() { + return this._transform.f; + } + + get scale() { + return this._transform.a; + } + + /** + * Change the scale, adjusting x/y by a given transform origin. + */ + scaleTo(scale: number, opts: ScaleToOpts = {}) { + let { originX = 0, originY = 0 } = opts; + + const { relativeTo = 'content', allowChangeEvent = false } = opts; + + const relativeToEl = relativeTo === 'content' ? this._positioningEl : this; + + // No content element? Fall back to just setting scale + if (!relativeToEl || !this._positioningEl) { + this.setTransform({ scale, allowChangeEvent }); + return; + } + + const rect = relativeToEl.getBoundingClientRect(); + originX = getAbsoluteValue(originX, rect.width); + originY = getAbsoluteValue(originY, rect.height); + + if (relativeTo === 'content') { + originX += this.x; + originY += this.y; + } else { + const currentRect = this._positioningEl.getBoundingClientRect(); + originX -= currentRect.left; + originY -= currentRect.top; + } + + this._applyChange({ + allowChangeEvent, + originX, + originY, + scaleDiff: scale / this.scale, + }); + } + + /** + * Update the stage with a given scale/x/y. + */ + setTransform(opts: SetTransformOpts = {}) { + const { scale = this.scale, allowChangeEvent = false } = opts; + + let { x = this.x, y = this.y } = opts; + + // If we don't have an element to position, just set the value as given. + // We'll check bounds later. + if (!this._positioningEl) { + this._updateTransform(scale, x, y, allowChangeEvent); + return; + } + + // Get current layout + const thisBounds = this.getBoundingClientRect(); + const positioningElBounds = this._positioningEl.getBoundingClientRect(); + + // Not displayed. May be disconnected or display:none. + // Just take the values, and we'll check bounds later. + if (!thisBounds.width || !thisBounds.height) { + this._updateTransform(scale, x, y, allowChangeEvent); + return; + } + + // Create points for _positioningEl. + let topLeft = createPoint(); + topLeft.x = positioningElBounds.left - thisBounds.left; + topLeft.y = positioningElBounds.top - thisBounds.top; + let bottomRight = createPoint(); + bottomRight.x = positioningElBounds.width + topLeft.x; + bottomRight.y = positioningElBounds.height + topLeft.y; + + // Calculate the intended position of _positioningEl. + const matrix = createMatrix() + .translate(x, y) + .scale(scale) + // Undo current transform + .multiply(this._transform.inverse()); + + topLeft = topLeft.matrixTransform(matrix); + bottomRight = bottomRight.matrixTransform(matrix); + + // Ensure _positioningEl can't move beyond out-of-bounds. + // Correct for x + if (topLeft.x > thisBounds.width) { + x += thisBounds.width - topLeft.x; + } else if (bottomRight.x < 0) { + x += -bottomRight.x; + } + + // Correct for y + if (topLeft.y > thisBounds.height) { + y += thisBounds.height - topLeft.y; + } else if (bottomRight.y < 0) { + y += -bottomRight.y; + } + + this._updateTransform(scale, x, y, allowChangeEvent); + } + + /** + * Update transform values without checking bounds. This is only called in setTransform. + */ + private _updateTransform( + scale: number, + x: number, + y: number, + allowChangeEvent: boolean, + ) { + // Avoid scaling to zero + if (scale < MIN_SCALE) return; + + // Return if there's no change + if (scale === this.scale && x === this.x && y === this.y) return; + + this._transform.e = x; + this._transform.f = y; + this._transform.d = this._transform.a = scale; + + this.style.setProperty('--x', this.x + 'px'); + this.style.setProperty('--y', this.y + 'px'); + this.style.setProperty('--scale', this.scale + ''); + + if (allowChangeEvent) { + const event = new Event('change', { bubbles: true }); + this.dispatchEvent(event); + } + } + + /** + * Called when the direct children of this element change. + * Until we have have shadow dom support across the board, we + * require a single element to be the child of , and + * that's the element we pan/scale. + */ + private _stageElChange() { + this._positioningEl = undefined; + + if (this.children.length === 0) return; + + this._positioningEl = this.children[0]; + + if (this.children.length > 1) { + console.warn(' must not have more than one child.'); + } + + // Do a bounds check + this.setTransform({ allowChangeEvent: true }); + } + + private _onWheel(event: WheelEvent) { + if (!this._positioningEl) return; + event.preventDefault(); + + const currentRect = this._positioningEl.getBoundingClientRect(); + let { deltaY } = event; + const { ctrlKey, deltaMode } = event; + + if (deltaMode === 1) { + // 1 is "lines", 0 is "pixels" + // Firefox uses "lines" for some types of mouse + deltaY *= 15; + } + + // ctrlKey is true when pinch-zooming on a trackpad. + const divisor = ctrlKey ? 100 : 300; + const scaleDiff = 1 - deltaY / divisor; + + this._applyChange({ + scaleDiff, + originX: event.clientX - currentRect.left, + originY: event.clientY - currentRect.top, + allowChangeEvent: true, + }); + } + + private _onPointerMove( + previousPointers: Pointer[], + currentPointers: Pointer[], + ) { + if (!this._positioningEl) return; + + // Combine next points with previous points + const currentRect = this._positioningEl.getBoundingClientRect(); + + // For calculating panning movement + const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]); + const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]); + + // Midpoint within the element + const originX = prevMidpoint.clientX - currentRect.left; + const originY = prevMidpoint.clientY - currentRect.top; + + // Calculate the desired change in scale + const prevDistance = getDistance(previousPointers[0], previousPointers[1]); + const newDistance = getDistance(currentPointers[0], currentPointers[1]); + const scaleDiff = prevDistance ? newDistance / prevDistance : 1; + + this._applyChange({ + originX, + originY, + scaleDiff, + panX: newMidpoint.clientX - prevMidpoint.clientX, + panY: newMidpoint.clientY - prevMidpoint.clientY, + allowChangeEvent: true, + }); + } + + /** Transform the view & fire a change event */ + private _applyChange(opts: ApplyChangeOpts = {}) { + const { + panX = 0, + panY = 0, + originX = 0, + originY = 0, + scaleDiff = 1, + allowChangeEvent = false, + } = opts; + + const matrix = createMatrix() + // Translate according to panning. + .translate(panX, panY) + // Scale about the origin. + .translate(originX, originY) + // Apply current translate + .translate(this.x, this.y) + .scale(scaleDiff) + .translate(-originX, -originY) + // Apply current scale. + .scale(this.scale); + + // Convert the transform into basic translate & scale. + this.setTransform({ + allowChangeEvent, + scale: matrix.a, + x: matrix.e, + y: matrix.f, + }); + } +} + +customElements.define('pinch-zoom', PinchZoom); diff --git a/src/client/lazy-app/Compress/Output/custom-els/PinchZoom/missing-types.d.ts b/src/client/lazy-app/Compress/Output/custom-els/PinchZoom/missing-types.d.ts new file mode 100644 index 00000000..5f28f1e7 --- /dev/null +++ b/src/client/lazy-app/Compress/Output/custom-els/PinchZoom/missing-types.d.ts @@ -0,0 +1,9 @@ +declare module 'preact' { + namespace createElement.JSX { + interface IntrinsicElements { + 'pinch-zoom': HTMLAttributes; + } + } +} + +export {}; diff --git a/src/client/lazy-app/Compress/Output/custom-els/PinchZoom/styles.css b/src/client/lazy-app/Compress/Output/custom-els/PinchZoom/styles.css new file mode 100644 index 00000000..83668f17 --- /dev/null +++ b/src/client/lazy-app/Compress/Output/custom-els/PinchZoom/styles.css @@ -0,0 +1,14 @@ +pinch-zoom { + display: block; + overflow: hidden; + touch-action: none; + --scale: 1; + --x: 0; + --y: 0; +} + +pinch-zoom > * { + transform: translate(var(--x), var(--y)) scale(var(--scale)); + transform-origin: 0 0; + will-change: transform; +} diff --git a/src/client/lazy-app/Compress/Output/custom-els/TwoUp/index.ts b/src/client/lazy-app/Compress/Output/custom-els/TwoUp/index.ts new file mode 100644 index 00000000..e003be25 --- /dev/null +++ b/src/client/lazy-app/Compress/Output/custom-els/TwoUp/index.ts @@ -0,0 +1,172 @@ +import PointerTracker, { Pointer } from 'pointer-tracker'; +import * as styles from './styles.css'; +import 'add-css:./styles.css'; + +const legacyClipCompatAttr = 'legacy-clip-compat'; +const orientationAttr = 'orientation'; + +type TwoUpOrientation = 'horizontal' | 'vertical'; + +/** + * A split view that the user can adjust. The first child becomes + * the left-hand side, and the second child becomes the right-hand side. + */ +export default class TwoUp extends HTMLElement { + static get observedAttributes() { + return [orientationAttr]; + } + + private readonly _handle = document.createElement('div'); + /** + * The position of the split in pixels. + */ + private _position = 0; + /** + * The position of the split in %. + */ + private _relativePosition = 0.5; + /** + * The value of _position when the pointer went down. + */ + private _positionOnPointerStart = 0; + /** + * Has connectedCallback been called yet? + */ + private _everConnected = false; + + constructor() { + super(); + this._handle.className = styles.twoUpHandle; + + // Watch for children changes. + // Note this won't fire for initial contents, + // so _childrenChange is also called in connectedCallback. + new MutationObserver(() => this._childrenChange()).observe(this, { + childList: true, + }); + + // Watch for element size changes. + if ('ResizeObserver' in window) { + new ResizeObserver(() => this._resetPosition()).observe(this); + } else { + window.addEventListener('resize', () => this._resetPosition()); + } + + // Watch for pointers on the handle. + const pointerTracker: PointerTracker = new PointerTracker(this._handle, { + start: (_, event) => { + // We only want to track 1 pointer. + if (pointerTracker.currentPointers.length === 1) return false; + event.preventDefault(); + this._positionOnPointerStart = this._position; + return true; + }, + move: () => { + this._pointerChange( + pointerTracker.startPointers[0], + pointerTracker.currentPointers[0], + ); + }, + }); + } + + connectedCallback() { + this._childrenChange(); + + this._handle.innerHTML = `
${`${''}`}
`; + + if (!this._everConnected) { + this._resetPosition(); + this._everConnected = true; + } + } + + attributeChangedCallback(name: string) { + if (name === orientationAttr) { + this._resetPosition(); + } + } + + private _resetPosition() { + // Set the initial position of the handle. + requestAnimationFrame(() => { + const bounds = this.getBoundingClientRect(); + const dimensionAxis = + this.orientation === 'vertical' ? 'height' : 'width'; + this._position = bounds[dimensionAxis] * this._relativePosition; + this._setPosition(); + }); + } + + /** + * If true, this element works in browsers that don't support clip-path (Edge). + * However, this means you'll have to set the height of this element manually. + */ + get legacyClipCompat() { + return this.hasAttribute(legacyClipCompatAttr); + } + + set legacyClipCompat(val: boolean) { + if (val) { + this.setAttribute(legacyClipCompatAttr, ''); + } else { + this.removeAttribute(legacyClipCompatAttr); + } + } + + /** + * Split vertically rather than horizontally. + */ + get orientation(): TwoUpOrientation { + const value = this.getAttribute(orientationAttr); + + // This mirrors the behaviour of input.type, where setting just sets the attribute, but getting + // returns the value only if it's valid. + if (value && value.toLowerCase() === 'vertical') return 'vertical'; + return 'horizontal'; + } + + set orientation(val: TwoUpOrientation) { + this.setAttribute(orientationAttr, val); + } + + /** + * Called when element's child list changes + */ + private _childrenChange() { + // Ensure the handle is the last child. + // The CSS depends on this. + if (this.lastElementChild !== this._handle) { + this.appendChild(this._handle); + } + } + + /** + * Called when a pointer moves. + */ + private _pointerChange(startPoint: Pointer, currentPoint: Pointer) { + const pointAxis = this.orientation === 'vertical' ? 'clientY' : 'clientX'; + const dimensionAxis = this.orientation === 'vertical' ? 'height' : 'width'; + const bounds = this.getBoundingClientRect(); + + this._position = + this._positionOnPointerStart + + (currentPoint[pointAxis] - startPoint[pointAxis]); + + // Clamp position to element bounds. + this._position = Math.max( + 0, + Math.min(this._position, bounds[dimensionAxis]), + ); + this._relativePosition = this._position / bounds[dimensionAxis]; + this._setPosition(); + } + + private _setPosition() { + this.style.setProperty('--split-point', `${this._position}px`); + } +} + +customElements.define('two-up', TwoUp); diff --git a/src/client/lazy-app/Compress/Output/custom-els/TwoUp/missing-types.d.ts b/src/client/lazy-app/Compress/Output/custom-els/TwoUp/missing-types.d.ts new file mode 100644 index 00000000..19e7f234 --- /dev/null +++ b/src/client/lazy-app/Compress/Output/custom-els/TwoUp/missing-types.d.ts @@ -0,0 +1,14 @@ +interface TwoUpAttributes extends preact.JSX.HTMLAttributes { + orientation?: string; + 'legacy-clip-compat'?: boolean; +} + +declare module 'preact' { + namespace createElement.JSX { + interface IntrinsicElements { + 'two-up': TwoUpAttributes; + } + } +} + +export {}; diff --git a/src/client/lazy-app/Compress/Output/custom-els/TwoUp/styles.css b/src/client/lazy-app/Compress/Output/custom-els/TwoUp/styles.css new file mode 100644 index 00000000..a823c7e0 --- /dev/null +++ b/src/client/lazy-app/Compress/Output/custom-els/TwoUp/styles.css @@ -0,0 +1,131 @@ +two-up { + display: grid; + position: relative; + --split-point: 0; + --accent-color: #777; + --track-color: var(--accent-color); + --thumb-background: #fff; + --thumb-color: var(--accent-color); + --thumb-size: 62px; + --bar-size: 6px; + --bar-touch-size: 30px; +} + +two-up > * { + /* Overlay all children on top of each other, and let two-up's layout contain all of them. */ + grid-area: 1/1; +} + +two-up[legacy-clip-compat] > :not(.two-up-handle) { + /* Legacy mode uses clip rather than clip-path (Edge doesn't support clip-path), but clip requires + elements to be positioned absolutely */ + position: absolute; +} + +.two-up-handle { + touch-action: none; + position: relative; + width: var(--bar-touch-size); + transform: translateX(var(--split-point)) translateX(-50%); + will-change: transform; + cursor: ew-resize; +} + +.two-up-handle::before { + content: ''; + display: block; + height: 100%; + width: var(--bar-size); + margin: 0 auto; + box-shadow: inset calc(var(--bar-size) / 2) 0 0 rgba(0, 0, 0, 0.1), + 0 1px 4px rgba(0, 0, 0, 0.4); + background: var(--track-color); +} + +.scrubber { + display: flex; + position: absolute; + top: 50%; + left: 50%; + transform-origin: 50% 50%; + transform: translate(-50%, -50%); + width: var(--thumb-size); + height: calc(var(--thumb-size) * 0.9); + background: var(--thumb-background); + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: var(--thumb-size); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + color: var(--thumb-color); + box-sizing: border-box; + padding: 0 calc(var(--thumb-size) * 0.24); +} + +.scrubber svg { + flex: 1; +} + +two-up[orientation='vertical'] .two-up-handle { + width: auto; + height: var(--bar-touch-size); + transform: translateY(var(--split-point)) translateY(-50%); + cursor: ns-resize; +} + +two-up[orientation='vertical'] .two-up-handle::before { + width: auto; + height: var(--bar-size); + box-shadow: inset 0 calc(var(--bar-size) / 2) 0 rgba(0, 0, 0, 0.1), + 0 1px 4px rgba(0, 0, 0, 0.4); + margin: calc((var(--bar-touch-size) - var(--bar-size)) / 2) 0 0 0; +} + +two-up[orientation='vertical'] .scrubber { + box-shadow: 1px 0 4px rgba(0, 0, 0, 0.1); + transform: translate(-50%, -50%) rotate(-90deg); +} + +two-up > :nth-child(1):not(.two-up-handle) { + -webkit-clip-path: inset(0 calc(100% - var(--split-point)) 0 0); + clip-path: inset(0 calc(100% - var(--split-point)) 0 0); +} + +two-up > :nth-child(2):not(.two-up-handle) { + -webkit-clip-path: inset(0 0 0 var(--split-point)); + clip-path: inset(0 0 0 var(--split-point)); +} + +two-up[orientation='vertical'] > :nth-child(1):not(.two-up-handle) { + -webkit-clip-path: inset(0 0 calc(100% - var(--split-point)) 0); + clip-path: inset(0 0 calc(100% - var(--split-point)) 0); +} + +two-up[orientation='vertical'] > :nth-child(2):not(.two-up-handle) { + -webkit-clip-path: inset(var(--split-point) 0 0 0); + clip-path: inset(var(--split-point) 0 0 0); +} + +/* + Even in legacy-clip-compat, prefer clip-path if it's supported. + It performs way better in Safari. + */ +@supports not ( + (clip-path: inset(0 0 0 0)) or (-webkit-clip-path: inset(0 0 0 0)) +) { + two-up[legacy-clip-compat] > :nth-child(1):not(.two-up-handle) { + clip: rect(auto var(--split-point) auto auto); + } + + two-up[legacy-clip-compat] > :nth-child(2):not(.two-up-handle) { + clip: rect(auto auto auto var(--split-point)); + } + + two-up[orientation='vertical'][legacy-clip-compat] + > :nth-child(1):not(.two-up-handle) { + clip: rect(auto auto var(--split-point) auto); + } + + two-up[orientation='vertical'][legacy-clip-compat] + > :nth-child(2):not(.two-up-handle) { + clip: rect(var(--split-point) auto auto auto); + } +} diff --git a/src/client/lazy-app/Compress/Output/index.tsx b/src/client/lazy-app/Compress/Output/index.tsx new file mode 100644 index 00000000..ccbdf41a --- /dev/null +++ b/src/client/lazy-app/Compress/Output/index.tsx @@ -0,0 +1,377 @@ +import { h, Component } from 'preact'; +import type PinchZoom from './custom-els/PinchZoom'; +import type { ScaleToOpts } from './custom-els/PinchZoom'; +import './custom-els/PinchZoom'; +import './custom-els/TwoUp'; +import * as style from './style.css'; +import 'add-css:./styles.css'; +import { shallowEqual, drawDataToCanvas } from '../../util'; +import { + ToggleBackgroundIcon, + AddIcon, + RemoveIcon, + BackIcon, + ToggleBackgroundActiveIcon, + RotateIcon, +} from '../../icons'; +import { twoUpHandle } from './custom-els/TwoUp/styles.css'; +import type { PreprocessorState } from '../../feature-meta'; +import { cleanSet } from '../../util/clean-modify'; +import type { SourceImage } from '../../Compress'; +import { linkRef } from 'shared/initial-app/util'; + +interface Props { + source?: SourceImage; + preprocessorState?: PreprocessorState; + mobileView: boolean; + leftCompressed?: ImageData; + rightCompressed?: ImageData; + leftImgContain: boolean; + rightImgContain: boolean; + onBack: () => void; + onPreprocessorChange: (newState: PreprocessorState) => void; +} + +interface State { + scale: number; + editingScale: boolean; + altBackground: boolean; +} + +const scaleToOpts: ScaleToOpts = { + originX: '50%', + originY: '50%', + relativeTo: 'container', + allowChangeEvent: true, +}; + +export default class Output extends Component { + state: State = { + scale: 1, + editingScale: false, + altBackground: false, + }; + canvasLeft?: HTMLCanvasElement; + canvasRight?: HTMLCanvasElement; + pinchZoomLeft?: PinchZoom; + pinchZoomRight?: PinchZoom; + scaleInput?: HTMLInputElement; + retargetedEvents = new WeakSet(); + + componentDidMount() { + const leftDraw = this.leftDrawable(); + const rightDraw = this.rightDrawable(); + + // Reset the pinch zoom, which may have an position set from the previous view, after pressing + // the back button. + this.pinchZoomLeft!.setTransform({ + allowChangeEvent: true, + x: 0, + y: 0, + scale: 1, + }); + + if (this.canvasLeft && leftDraw) { + drawDataToCanvas(this.canvasLeft, leftDraw); + } + if (this.canvasRight && rightDraw) { + drawDataToCanvas(this.canvasRight, rightDraw); + } + } + + componentDidUpdate(prevProps: Props, prevState: State) { + const prevLeftDraw = this.leftDrawable(prevProps); + const prevRightDraw = this.rightDrawable(prevProps); + const leftDraw = this.leftDrawable(); + const rightDraw = this.rightDrawable(); + const sourceFileChanged = + // Has the value become (un)defined? + !!this.props.source !== !!prevProps.source || + // Or has the file changed? + (this.props.source && + prevProps.source && + this.props.source.file !== prevProps.source.file); + + const oldSourceData = prevProps.source && prevProps.source.preprocessed; + const newSourceData = this.props.source && this.props.source.preprocessed; + const pinchZoom = this.pinchZoomLeft!; + + if (sourceFileChanged) { + // New image? Reset the pinch-zoom. + pinchZoom.setTransform({ + allowChangeEvent: true, + x: 0, + y: 0, + scale: 1, + }); + } else if ( + oldSourceData && + newSourceData && + oldSourceData !== newSourceData + ) { + // Since the pinch zoom transform origin is the top-left of the content, we need to flip + // things around a bit when the content size changes, so the new content appears as if it were + // central to the previous content. + const scaleChange = 1 - pinchZoom.scale; + const oldXScaleOffset = (oldSourceData.width / 2) * scaleChange; + const oldYScaleOffset = (oldSourceData.height / 2) * scaleChange; + + pinchZoom.setTransform({ + allowChangeEvent: true, + x: pinchZoom.x - oldXScaleOffset + oldYScaleOffset, + y: pinchZoom.y - oldYScaleOffset + oldXScaleOffset, + }); + } + + if (leftDraw && leftDraw !== prevLeftDraw && this.canvasLeft) { + drawDataToCanvas(this.canvasLeft, leftDraw); + } + if (rightDraw && rightDraw !== prevRightDraw && this.canvasRight) { + drawDataToCanvas(this.canvasRight, rightDraw); + } + } + + shouldComponentUpdate(nextProps: Props, nextState: State) { + return ( + !shallowEqual(this.props, nextProps) || + !shallowEqual(this.state, nextState) + ); + } + + private leftDrawable(props: Props = this.props): ImageData | undefined { + return props.leftCompressed || (props.source && props.source.preprocessed); + } + + private rightDrawable(props: Props = this.props): ImageData | undefined { + return props.rightCompressed || (props.source && props.source.preprocessed); + } + + private toggleBackground = () => { + this.setState({ + altBackground: !this.state.altBackground, + }); + }; + + private zoomIn = () => { + if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); + this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts); + }; + + private zoomOut = () => { + if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); + this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts); + }; + + private onRotateClick = () => { + const { preprocessorState: inputProcessorState } = this.props; + if (!inputProcessorState) return; + + const newState = cleanSet( + inputProcessorState, + 'rotate.rotate', + (inputProcessorState.rotate.rotate + 90) % 360, + ); + + this.props.onPreprocessorChange(newState); + }; + + private onScaleValueFocus = () => { + this.setState({ editingScale: true }, () => { + if (this.scaleInput) { + // Firefox unfocuses the input straight away unless I force a style + // calculation here. I have no idea why, but it's late and I'm quite + // tired. + getComputedStyle(this.scaleInput).transform; + this.scaleInput.focus(); + } + }); + }; + + private onScaleInputBlur = () => { + this.setState({ editingScale: false }); + }; + + private onScaleInputChanged = (event: Event) => { + const target = event.target as HTMLInputElement; + const percent = parseFloat(target.value); + if (isNaN(percent)) return; + if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); + + this.pinchZoomLeft.scaleTo(percent / 100, scaleToOpts); + }; + + private onPinchZoomLeftChange = (event: Event) => { + if (!this.pinchZoomRight || !this.pinchZoomLeft) { + throw Error('Missing pinch-zoom element'); + } + this.setState({ + scale: this.pinchZoomLeft.scale, + }); + this.pinchZoomRight.setTransform({ + scale: this.pinchZoomLeft.scale, + x: this.pinchZoomLeft.x, + y: this.pinchZoomLeft.y, + }); + }; + + /** + * We're using two pinch zoom elements, but we want them to stay in sync. When one moves, we + * update the position of the other. However, this is tricky when it comes to multi-touch, when + * one finger is on one pinch-zoom, and the other finger is on the other. To overcome this, we + * redirect all relevant pointer/touch/mouse events to the first pinch zoom element. + * + * @param event Event to redirect + */ + private onRetargetableEvent = (event: Event) => { + const targetEl = event.target as HTMLElement; + if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); + // If the event is on the handle of the two-up, let it through, + // unless it's a wheel event, in which case always let it through. + if (event.type !== 'wheel' && targetEl.closest(`.${twoUpHandle}`)) return; + // If we've already retargeted this event, let it through. + if (this.retargetedEvents.has(event)) return; + // Stop the event in its tracks. + event.stopImmediatePropagation(); + event.preventDefault(); + // Clone the event & dispatch + // Some TypeScript trickery needed due to https://github.com/Microsoft/TypeScript/issues/3841 + const clonedEvent = new (event.constructor as typeof Event)( + event.type, + event, + ); + this.retargetedEvents.add(clonedEvent); + this.pinchZoomLeft.dispatchEvent(clonedEvent); + + // Unfocus any active element on touchend. This fixes an issue on (at least) Android Chrome, + // where the software keyboard is hidden, but the input remains focused, then after interaction + // with this element the keyboard reappears for NO GOOD REASON. Thanks Android. + if ( + event.type === 'touchend' && + document.activeElement && + document.activeElement instanceof HTMLElement + ) { + document.activeElement.blur(); + } + }; + + render( + { mobileView, leftImgContain, rightImgContain, source, onBack }: Props, + { scale, editingScale, altBackground }: State, + ) { + const leftDraw = this.leftDrawable(); + const rightDraw = this.rightDrawable(); + // To keep position stable, the output is put in a square using the longest dimension. + const originalImage = source && source.preprocessed; + + return ( +
+ + + + + + + + + +
+ +
+ +
+
+ + {editingScale ? ( + + ) : ( + + {Math.round(scale * 100)}% + + )} + +
+
+ + +
+
+
+ ); + } +} diff --git a/src/client/lazy-app/Compress/Output/style.css b/src/client/lazy-app/Compress/Output/style.css new file mode 100644 index 00000000..3434be1a --- /dev/null +++ b/src/client/lazy-app/Compress/Output/style.css @@ -0,0 +1,166 @@ +.output { + composes: abs-fill from '../../../../shared/initial-app/util.scss'; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #000; + opacity: 0; + transition: opacity 500ms ease; + } + + &.alt-background::before { + opacity: 0.6; + } +} + +.two-up { + composes: abs-fill from '../../../../shared/initial-app/util.scss'; + --accent-color: var(--button-fg); +} + +.pinch-zoom { + composes: abs-fill from '../../../../shared/initial-app/util.scss'; + outline: none; + display: flex; + justify-content: center; + align-items: center; +} + +.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 + will-change: auto; + // Prevent the image becoming misshapen due to default flexbox layout. + flex-shrink: 0; +} + +.controls { + position: absolute; + display: flex; + justify-content: center; + top: 0; + left: 0; + right: 0; + padding: 9px 84px; + overflow: hidden; + flex-wrap: wrap; + contain: content; + + // Allow clicks to fall through to the pinch zoom area + pointer-events: none; + & > * { + pointer-events: auto; + } + + @media (min-width: 860px) { + padding: 9px; + top: auto; + left: 320px; + right: 320px; + bottom: 0; + flex-wrap: wrap-reverse; + } +} + +.zoom-controls { + display: flex; + + & :not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: 0; + } + & :not(:last-child) { + margin-right: 0; + border-right-width: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +} + +.button, +.zoom { + display: flex; + align-items: center; + box-sizing: border-box; + margin: 4px; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 5px; + line-height: 1; + white-space: nowrap; + height: 36px; + padding: 0 8px; + cursor: pointer; + + @media (min-width: 600px) { + height: 48px; + padding: 0 16px; + } + + &:focus { + box-shadow: 0 0 0 2px var(--button-fg); + outline: none; + z-index: 1; + } +} + +.button { + color: var(--button-fg); + + &:hover { + background-color: #eee; + } + + &.active { + background: #34b9eb; + color: #fff; + + &:hover { + background: #32a3ce; + } + } +} + +.zoom { + color: #625e80; + cursor: text; + width: 6em; + font: inherit; + text-align: center; + justify-content: center; + + &:focus { + box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.2), 0 0 0 2px var(--button-fg); + } +} + +.zoom-value { + position: relative; + top: 1px; + margin: 0 3px 0 0; + color: #888; + border-bottom: 1px dashed #999; +} + +.back { + position: absolute; + top: 0; + left: 0; + padding: 9px; +} + +.buttons-no-wrap { + display: flex; + 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 48446fc9..c730d450 100644 --- a/src/client/lazy-app/Compress/index.tsx +++ b/src/client/lazy-app/Compress/index.tsx @@ -22,8 +22,8 @@ import { EncoderType, EncoderOptions, } from '../feature-meta'; -import Output from '../Output'; -import Options from '../Options'; +import Output from './Output'; +import Options from './Options'; import ResultCache from './result-cache'; import { cleanMerge, cleanSet } from '../util/clean-modify'; import './custom-els/MultiPanel'; diff --git a/src/client/lazy-app/icons/index.tsx b/src/client/lazy-app/icons/index.tsx new file mode 100644 index 00000000..7b1f19fb --- /dev/null +++ b/src/client/lazy-app/icons/index.tsx @@ -0,0 +1,108 @@ +import { h } from 'preact'; + +const Icon = (props: preact.JSX.HTMLAttributes) => ( + // @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019 + +); + +export const DownloadIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const ToggleBackgroundIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const ToggleBackgroundActiveIcon = ( + props: preact.JSX.HTMLAttributes, +) => ( + + + +); + +export const RotateIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const AddIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const RemoveIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const UncheckedIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const CheckedIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const ExpandIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +export const BackIcon = (props: preact.JSX.HTMLAttributes) => ( + + + +); + +const copyAcrossRotations = { + up: 90, + right: 180, + down: -90, + left: 0, +}; + +export interface CopyAcrossIconProps extends preact.JSX.HTMLAttributes { + copyDirection: keyof typeof copyAcrossRotations; +} + +export const CopyAcrossIcon = (props: CopyAcrossIconProps) => { + const { copyDirection, ...otherProps } = props; + const id = 'point-' + copyDirection; + const rotation = copyAcrossRotations[copyDirection]; + + return ( + + + + + + + + + ); +}; diff --git a/src/client/missing-types.d.ts b/src/client/missing-types.d.ts index 30a22647..a176b0a8 100644 --- a/src/client/missing-types.d.ts +++ b/src/client/missing-types.d.ts @@ -25,3 +25,23 @@ declare module 'service-worker:*' { } declare module 'preact/debug' {} + +interface ResizeObserverCallback { + (entries: ResizeObserverEntry[], observer: ResizeObserver): void; +} + +interface ResizeObserverEntry { + readonly target: Element; + readonly contentRect: DOMRectReadOnly; +} + +interface ResizeObserver { + observe(target: Element): void; + unobserve(target: Element): void; + disconnect(): void; +} + +declare var ResizeObserver: { + prototype: ResizeObserver; + new (callback: ResizeObserverCallback): ResizeObserver; +};