Adding output

This commit is contained in:
Jake Archibald
2020-11-10 13:12:31 +00:00
parent 6d0d9dc022
commit 196e6e1aea
13 changed files with 1396 additions and 2 deletions

6
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 <pinch-zoom>, 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('<pinch-zoom> 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);

View File

@@ -0,0 +1,9 @@
declare module 'preact' {
namespace createElement.JSX {
interface IntrinsicElements {
'pinch-zoom': HTMLAttributes;
}
}
}
export {};

View File

@@ -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;
}

View File

@@ -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 = `<div class="${
styles.scrubber
}">${`<svg viewBox="0 0 27 20" fill="currentColor">${'<path d="M17 19.2l9.5-9.6L16.9 0zM9.6 0L0 9.6l9.6 9.6z"/>'}</svg>`}</div>`;
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);

View File

@@ -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 {};

View File

@@ -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);
}
}

View File

@@ -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<Props, State> {
state: State = {
scale: 1,
editingScale: false,
altBackground: false,
};
canvasLeft?: HTMLCanvasElement;
canvasRight?: HTMLCanvasElement;
pinchZoomLeft?: PinchZoom;
pinchZoomRight?: PinchZoom;
scaleInput?: HTMLInputElement;
retargetedEvents = new WeakSet<Event>();
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 (
<div
class={`${style.output} ${altBackground ? style.altBackground : ''}`}
>
<two-up
legacy-clip-compat
class={style.twoUp}
orientation={mobileView ? 'vertical' : 'horizontal'}
// Event redirecting. See onRetargetableEvent.
onTouchStartCapture={this.onRetargetableEvent}
onTouchEndCapture={this.onRetargetableEvent}
onTouchMoveCapture={this.onRetargetableEvent}
onPointerDownCapture={this.onRetargetableEvent}
onMouseDownCapture={this.onRetargetableEvent}
onWheelCapture={this.onRetargetableEvent}
>
<pinch-zoom
class={style.pinchZoom}
onChange={this.onPinchZoomLeftChange}
ref={linkRef(this, 'pinchZoomLeft')}
>
<canvas
class={style.pinchTarget}
ref={linkRef(this, 'canvasLeft')}
width={leftDraw && leftDraw.width}
height={leftDraw && leftDraw.height}
style={{
width: originalImage ? originalImage.width : '',
height: originalImage ? originalImage.height : '',
objectFit: leftImgContain ? 'contain' : '',
}}
/>
</pinch-zoom>
<pinch-zoom
class={style.pinchZoom}
ref={linkRef(this, 'pinchZoomRight')}
>
<canvas
class={style.pinchTarget}
ref={linkRef(this, 'canvasRight')}
width={rightDraw && rightDraw.width}
height={rightDraw && rightDraw.height}
style={{
width: originalImage ? originalImage.width : '',
height: originalImage ? originalImage.height : '',
objectFit: rightImgContain ? 'contain' : '',
}}
/>
</pinch-zoom>
</two-up>
<div class={style.back}>
<button class={style.button} onClick={onBack}>
<BackIcon />
</button>
</div>
<div class={style.controls}>
<div class={style.zoomControls}>
<button class={style.button} onClick={this.zoomOut}>
<RemoveIcon />
</button>
{editingScale ? (
<input
type="number"
step="1"
min="1"
max="1000000"
ref={linkRef(this, 'scaleInput')}
class={style.zoom}
value={Math.round(scale * 100)}
onInput={this.onScaleInputChanged}
onBlur={this.onScaleInputBlur}
/>
) : (
<span
class={style.zoom}
tabIndex={0}
onFocus={this.onScaleValueFocus}
>
<span class={style.zoomValue}>{Math.round(scale * 100)}</span>%
</span>
)}
<button class={style.button} onClick={this.zoomIn}>
<AddIcon />
</button>
</div>
<div class={style.buttonsNoWrap}>
<button
class={style.button}
onClick={this.onRotateClick}
title="Rotate image"
>
<RotateIcon />
</button>
<button
class={`${style.button} ${altBackground ? style.active : ''}`}
onClick={this.toggleBackground}
title="Change canvas color"
>
{altBackground ? (
<ToggleBackgroundActiveIcon />
) : (
<ToggleBackgroundIcon />
)}
</button>
</div>
</div>
</div>
);
}
}

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -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
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
/>
);
export const DownloadIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M19 12v7H5v-7H3v7c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2v-7h-2zm-6 .7l2.6-2.6 1.4 1.4-5 5-5-5 1.4-1.4 2.6 2.6V3h2z" />
</Icon>
);
export const ToggleBackgroundIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3c0 1.1.9 2 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2 2v10c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z" />
</Icon>
);
export const ToggleBackgroundActiveIcon = (
props: preact.JSX.HTMLAttributes,
) => (
<Icon {...props}>
<path d="M9 7H7v2h2V7zm0 4H7v2h2v-2zm0-8a2 2 0 0 0-2 2h2V3zm4 12h-2v2h2v-2zm6-12v2h2a2 2 0 0 0-2-2zm-6 0h-2v2h2V3zM9 17v-2H7c0 1.1.9 2 2 2zm10-4h2v-2h-2v2zm0-4h2V7h-2v2zm0 8a2 2 0 0 0 2-2h-2v2zM5 7H3v12c0 1.1.9 2 2 2h12v-2H5V7zm10-2h2V3h-2v2zm0 12h2v-2h-2v2z" />
</Icon>
);
export const RotateIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M15.6 5.5L11 1v3a8 8 0 0 0 0 16v-2a6 6 0 0 1 0-12v4l4.5-4.5zm4.3 5.5a8 8 0 0 0-1.6-3.9L17 8.5c.5.8.9 1.6 1 2.5h2zM13 17.9v2a8 8 0 0 0 3.9-1.6L15.5 17c-.8.5-1.6.9-2.5 1zm3.9-2.4l1.4 1.4A8 8 0 0 0 20 13h-2c-.1.9-.5 1.7-1 2.5z" />
</Icon>
);
export const AddIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</Icon>
);
export const RemoveIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M19 13H5v-2h14v2z" />
</Icon>
);
export const UncheckedIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M21.3 2.7v18.6H2.7V2.7h18.6m0-2.7H2.7A2.7 2.7 0 0 0 0 2.7v18.6A2.7 2.7 0 0 0 2.7 24h18.6a2.7 2.7 0 0 0 2.7-2.7V2.7A2.7 2.7 0 0 0 21.3 0z" />
</Icon>
);
export const CheckedIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M21.3 0H2.7A2.7 2.7 0 0 0 0 2.7v18.6A2.7 2.7 0 0 0 2.7 24h18.6a2.7 2.7 0 0 0 2.7-2.7V2.7A2.7 2.7 0 0 0 21.3 0zm-12 18.7L2.7 12l1.8-1.9L9.3 15 19.5 4.8l1.8 1.9z" />
</Icon>
);
export const ExpandIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z" />
</Icon>
);
export const BackIcon = (props: preact.JSX.HTMLAttributes) => (
<Icon {...props}>
<path d="M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20v-2z" />
</Icon>
);
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 (
<Icon {...otherProps}>
<defs>
<clipPath id={id}>
<path
d="M-12-12v24h24v-24zM4.5 2h-4v3l-5-5 5-5v3h4z"
transform={`translate(12 13) rotate(${rotation})`}
/>
</clipPath>
</defs>
<path
clip-path={`url(#${id})`}
d="M19 3h-4.2c-.4-1.2-1.5-2-2.8-2s-2.4.8-2.8 2H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-7 0a1 1 0 0 1 0 2c-.6 0-1-.4-1-1s.4-1 1-1z"
/>
</Icon>
);
};

View File

@@ -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;
};