mirror of
https://github.com/GoogleChromeLabs/squoosh.git
synced 2025-11-12 08:47:31 +00:00
Add Transform modal
This commit is contained in:
37
src/client/lazy-app/Compress/CanvasImage.tsx
Normal file
37
src/client/lazy-app/Compress/CanvasImage.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { h, Component, createRef } from 'preact';
|
||||||
|
import { drawDataToCanvas } from '../util';
|
||||||
|
|
||||||
|
export interface CanvasImageProps
|
||||||
|
extends h.JSX.HTMLAttributes<HTMLCanvasElement> {
|
||||||
|
image?: ImageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class CanvasImage extends Component<CanvasImageProps> {
|
||||||
|
canvas = createRef<HTMLCanvasElement>();
|
||||||
|
componentDidUpdate(prevProps: CanvasImageProps) {
|
||||||
|
if (this.props.image !== prevProps.image) {
|
||||||
|
this.draw(this.props.image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.props.image) {
|
||||||
|
this.draw(this.props.image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw(image?: ImageData) {
|
||||||
|
const canvas = this.canvas.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!image) canvas.getContext('2d');
|
||||||
|
else drawDataToCanvas(canvas, image);
|
||||||
|
}
|
||||||
|
render({ image, ...props }: CanvasImageProps) {
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={this.canvas}
|
||||||
|
width={image?.width}
|
||||||
|
height={image?.height}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
388
src/client/lazy-app/Compress/Transform/Cropper/index.tsx
Normal file
388
src/client/lazy-app/Compress/Transform/Cropper/index.tsx
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import { h, Component, ComponentChildren } from 'preact';
|
||||||
|
import * as style from './style.css';
|
||||||
|
import 'add-css:./style.css';
|
||||||
|
import { shallowEqual } from 'client/lazy-app/util';
|
||||||
|
|
||||||
|
export interface CropBox {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum CropBox size
|
||||||
|
const MIN_SIZE = 2;
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
size: { width: number; height: number };
|
||||||
|
scale?: number;
|
||||||
|
lockAspect?: boolean;
|
||||||
|
crop: CropBox;
|
||||||
|
onChange?(crop: CropBox): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Edge = keyof CropBox;
|
||||||
|
|
||||||
|
interface PointerTrack {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
edges: { edge: Edge; value: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
crop: CropBox;
|
||||||
|
pan: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Cropper extends Component<Props, State> {
|
||||||
|
private pointers = new Map<number, PointerTrack>();
|
||||||
|
|
||||||
|
state = {
|
||||||
|
crop: this.normalizeCrop({ ...this.props.crop }),
|
||||||
|
pan: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
shouldComponentUpdate(nextProps: Props, nextState: State) {
|
||||||
|
if (!shallowEqual(nextState, this.state)) return true;
|
||||||
|
const { size, scale, lockAspect, crop } = this.props;
|
||||||
|
return (
|
||||||
|
size.width !== nextProps.size.width ||
|
||||||
|
size.height !== nextProps.size.height ||
|
||||||
|
scale !== nextProps.scale ||
|
||||||
|
lockAspect !== nextProps.lockAspect ||
|
||||||
|
!shallowEqual(crop, nextProps.crop)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps({ crop }: Props, nextState: State) {
|
||||||
|
const current = nextState.crop || this.state.crop;
|
||||||
|
if (crop !== this.props.crop && !shallowEqual(crop, current)) {
|
||||||
|
// this.setState({ crop: nextProps.crop });
|
||||||
|
this.setCrop(crop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCrop(crop: CropBox) {
|
||||||
|
crop.left = Math.round(Math.max(0, crop.left));
|
||||||
|
crop.top = Math.round(Math.max(0, crop.top));
|
||||||
|
crop.right = Math.round(Math.max(0, crop.right));
|
||||||
|
crop.bottom = Math.round(Math.max(0, crop.bottom));
|
||||||
|
return crop;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCrop(cropUpdate: Partial<CropBox>) {
|
||||||
|
const crop = this.normalizeCrop({ ...this.state.crop, ...cropUpdate });
|
||||||
|
// ignore crop updates that normalize to the same values
|
||||||
|
const old = this.state.crop;
|
||||||
|
if (
|
||||||
|
crop.left === old.left &&
|
||||||
|
crop.right === old.right &&
|
||||||
|
crop.top === old.top &&
|
||||||
|
crop.bottom === old.bottom
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// crop.left = Math.max(0, crop.left) | 0;
|
||||||
|
// crop.top = Math.max(0, crop.top) | 0;
|
||||||
|
// crop.right = Math.max(0, crop.right) | 0;
|
||||||
|
// crop.bottom = Math.max(0, crop.bottom) | 0;
|
||||||
|
this.setState({ crop });
|
||||||
|
if (this.props.onChange) {
|
||||||
|
this.props.onChange(crop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.button !== 0 || this.state.pan) return;
|
||||||
|
|
||||||
|
const target = event.target as SVGElement;
|
||||||
|
const edgeAttr = target.getAttribute('data-edge');
|
||||||
|
if (edgeAttr) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const edges = edgeAttr.split(/ *, */) as Edge[];
|
||||||
|
// console.log(this.props.lockAspect);
|
||||||
|
if (this.props.lockAspect && edges.length === 1) return;
|
||||||
|
|
||||||
|
this.pointers.set(event.pointerId, {
|
||||||
|
x: event.x,
|
||||||
|
y: event.y,
|
||||||
|
edges: edges.map((edge) => ({ edge, value: this.state.crop[edge] })),
|
||||||
|
});
|
||||||
|
target.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPointerMove = (event: PointerEvent) => {
|
||||||
|
const target = event.target as SVGElement;
|
||||||
|
const down = this.pointers.get(event.pointerId);
|
||||||
|
if (down && target.hasPointerCapture(event.pointerId)) {
|
||||||
|
const { size } = this.props;
|
||||||
|
const oldCrop = this.state.crop;
|
||||||
|
const aspect =
|
||||||
|
(size.width - oldCrop.left - oldCrop.right) /
|
||||||
|
(size.height - oldCrop.top - oldCrop.bottom);
|
||||||
|
const scale = this.props.scale || 1;
|
||||||
|
let dx = (event.x - down.x) / scale;
|
||||||
|
let dy = (event.y - down.y) / scale;
|
||||||
|
// console.log(this.props.lockAspect, aspect);
|
||||||
|
if (this.props.lockAspect) {
|
||||||
|
const dir = (dx + dy) / 2;
|
||||||
|
dx = dir * aspect;
|
||||||
|
dy = dir / aspect;
|
||||||
|
}
|
||||||
|
const crop: Partial<CropBox> = {};
|
||||||
|
for (const { edge, value } of down.edges) {
|
||||||
|
let edgeValue = value;
|
||||||
|
switch (edge) {
|
||||||
|
case 'left':
|
||||||
|
edgeValue += dx;
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
edgeValue -= dx;
|
||||||
|
break;
|
||||||
|
case 'top':
|
||||||
|
edgeValue += dy;
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
edgeValue -= dy;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
crop[edge] = edgeValue;
|
||||||
|
}
|
||||||
|
// Prevent MOVE from resizing the cropbox:
|
||||||
|
if (crop.left && crop.right) {
|
||||||
|
if (crop.left < 0) crop.right += crop.left;
|
||||||
|
if (crop.right < 0) crop.left += crop.right;
|
||||||
|
} else {
|
||||||
|
// enforce minimum 1px cropbox width
|
||||||
|
if (crop.left)
|
||||||
|
crop.left = Math.min(
|
||||||
|
crop.left,
|
||||||
|
size.width - oldCrop.right - MIN_SIZE,
|
||||||
|
);
|
||||||
|
if (crop.right)
|
||||||
|
crop.right = Math.min(
|
||||||
|
crop.right,
|
||||||
|
size.width - oldCrop.left - MIN_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (crop.top && crop.bottom) {
|
||||||
|
if (crop.top < 0) crop.bottom += crop.top;
|
||||||
|
if (crop.bottom < 0) crop.top += crop.bottom;
|
||||||
|
} else {
|
||||||
|
// enforce minimum 1px cropbox height
|
||||||
|
if (crop.top)
|
||||||
|
crop.top = Math.min(
|
||||||
|
crop.top,
|
||||||
|
size.height - oldCrop.bottom - MIN_SIZE,
|
||||||
|
);
|
||||||
|
if (crop.bottom)
|
||||||
|
crop.bottom = Math.min(
|
||||||
|
crop.bottom,
|
||||||
|
size.height - oldCrop.top - MIN_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.setCrop(crop);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPointerUp = (event: PointerEvent) => {
|
||||||
|
const target = event.target as SVGElement;
|
||||||
|
const down = this.pointers.get(event.pointerId);
|
||||||
|
if (down && target.hasPointerCapture(event.pointerId)) {
|
||||||
|
this.onPointerMove(event);
|
||||||
|
target.releasePointerCapture(event.pointerId);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
this.pointers.delete(event.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === ' ') {
|
||||||
|
if (!this.state.pan) {
|
||||||
|
this.setState({ pan: true });
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === ' ') this.setState({ pan: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
addEventListener('keydown', this.onKeyDown);
|
||||||
|
addEventListener('keyup', this.onKeyUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
addEventListener('keydown', this.onKeyDown);
|
||||||
|
addEventListener('keyup', this.onKeyUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
render({ size, scale }: Props, { crop, pan }: State) {
|
||||||
|
const x = crop.left;
|
||||||
|
const y = crop.top;
|
||||||
|
const width = size.width - crop.left - crop.right;
|
||||||
|
const height = size.height - crop.top - crop.bottom;
|
||||||
|
// const x = crop.left.toFixed(2);
|
||||||
|
// const y = crop.top.toFixed(2);
|
||||||
|
// const width = (size.width - crop.left - crop.right).toFixed(2);
|
||||||
|
// const height = (size.height - crop.top - crop.bottom).toFixed(2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
class={`${style.cropper} ${pan ? style.pan : ''}`}
|
||||||
|
width={size.width + 20}
|
||||||
|
height={size.height + 20}
|
||||||
|
viewBox={`-10 -10 ${size.width + 20} ${size.height + 20}`}
|
||||||
|
style={{
|
||||||
|
// this is hack to force style invalidation in Chrome
|
||||||
|
zoom: (scale || 1).toFixed(3),
|
||||||
|
}}
|
||||||
|
onPointerDown={this.onPointerDown}
|
||||||
|
onPointerMove={this.onPointerMove}
|
||||||
|
onPointerUp={this.onPointerUp}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/*
|
||||||
|
<clipPath id="bg">
|
||||||
|
<rect x={x} y={y} width={width} height={height} />
|
||||||
|
</clipPath>
|
||||||
|
*/}
|
||||||
|
{/*
|
||||||
|
<filter id="shadow" x="-2" y="-2" width="4" height="4">
|
||||||
|
<feDropShadow
|
||||||
|
dx="0"
|
||||||
|
dy="0.5"
|
||||||
|
stdDeviation="1.5"
|
||||||
|
flood-color="#000"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
<filter id="shadow2" x="-2" y="-2" width="4" height="4">
|
||||||
|
<feDropShadow
|
||||||
|
dx="0"
|
||||||
|
dy="0.25"
|
||||||
|
stdDeviation="0.5"
|
||||||
|
flood-color="rgba(0,0,0,0.5)"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
*/}
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
class={style.background}
|
||||||
|
width={size.width}
|
||||||
|
height={size.height}
|
||||||
|
// mask="url(#bg)"
|
||||||
|
// clip-path="url(#bg)"
|
||||||
|
// style={{
|
||||||
|
// clipPath: `polygon(0 0, 0 100%, 100% 100%, 100% 0, 0 0, ${x}px ${y}px, ${x+width}px ${y}px, ${x+width}px ${y+height}px, ${x}px ${y+height}px, ${x}px ${y}px)`
|
||||||
|
// }}
|
||||||
|
clip-path={`polygon(0 0, 0 100%, 100% 100%, 100% 0, 0 0, ${x}px ${y}px, ${
|
||||||
|
x + width
|
||||||
|
}px ${y}px, ${x + width}px ${y + height}px, ${x}px ${
|
||||||
|
y + height
|
||||||
|
}px, ${x}px ${y}px)`}
|
||||||
|
/>
|
||||||
|
<svg x={x} y={y} width={width} height={height}>
|
||||||
|
<Freezer>
|
||||||
|
<rect
|
||||||
|
id="box"
|
||||||
|
class={style.cropbox}
|
||||||
|
data-edge="left,right,top,bottom"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
// filter="url(#shadow2)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect class={style.edge} data-edge="top" width="100%" />
|
||||||
|
<rect class={style.edge} data-edge="bottom" width="100%" y="100%" />
|
||||||
|
<rect class={style.edge} data-edge="left" height="100%" />
|
||||||
|
<rect class={style.edge} data-edge="right" height="100%" x="100%" />
|
||||||
|
|
||||||
|
<circle
|
||||||
|
class={style.corner}
|
||||||
|
data-edge="left,top"
|
||||||
|
// filter="url(#shadow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
class={style.corner}
|
||||||
|
data-edge="right,top"
|
||||||
|
cx="100%"
|
||||||
|
// filter="url(#shadow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
class={style.corner}
|
||||||
|
data-edge="right,bottom"
|
||||||
|
cx="100%"
|
||||||
|
cy="100%"
|
||||||
|
// filter="url(#shadow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
class={style.corner}
|
||||||
|
data-edge="left,bottom"
|
||||||
|
cy="100%"
|
||||||
|
// filter="url(#shadow)"
|
||||||
|
/>
|
||||||
|
</Freezer>
|
||||||
|
</svg>
|
||||||
|
{/*
|
||||||
|
<rect
|
||||||
|
id="box"
|
||||||
|
class={style.cropbox}
|
||||||
|
data-edge="left,right,top,bottom"
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
class={`${style.edge} ${style.top}`}
|
||||||
|
data-edge="top"
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={width}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
class={`${style.edge} ${style.bottom}`}
|
||||||
|
data-edge="bottom"
|
||||||
|
x={x}
|
||||||
|
y={size.height - crop.bottom}
|
||||||
|
width={width}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
class={`${style.edge} ${style.left}`}
|
||||||
|
data-edge="left"
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
class={`${style.edge} ${style.right}`}
|
||||||
|
data-edge="right"
|
||||||
|
x={size.width - crop.right}
|
||||||
|
y={y}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
*/}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FreezerProps {
|
||||||
|
children: ComponentChildren;
|
||||||
|
}
|
||||||
|
class Freezer extends Component<FreezerProps> {
|
||||||
|
shouldComponentUpdate() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
render({ children }: FreezerProps) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/client/lazy-app/Compress/Transform/Cropper/style.css
Normal file
119
src/client/lazy-app/Compress/Transform/Cropper/style.css
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.cropper {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(-10px / var(--scale, 1));
|
||||||
|
top: calc(-10px / var(--scale, 1));
|
||||||
|
right: calc(-10px / var(--scale, 1));
|
||||||
|
bottom: calc(-10px / var(--scale, 1));
|
||||||
|
shape-rendering: crispedges;
|
||||||
|
overflow: hidden;
|
||||||
|
contain: strict;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
transform: scale(calc(1 / var(--scale))) !important;
|
||||||
|
zoom: var(--scale, 1);
|
||||||
|
|
||||||
|
&.pan {
|
||||||
|
cursor: grabbing;
|
||||||
|
& * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > svg {
|
||||||
|
margin: -10px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: visible;
|
||||||
|
contain: strict;
|
||||||
|
/* overflow: visible; */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.background {
|
||||||
|
pointer-events: none;
|
||||||
|
fill: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropbox {
|
||||||
|
fill: none;
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: calc(1.5 / var(--scale, 1));
|
||||||
|
stroke-dasharray: calc(5 / var(--scale, 1)), calc(5 / var(--scale, 1));
|
||||||
|
stroke-dashoffset: 50%;
|
||||||
|
/* Accept pointer input even though this is unpainted transparent */
|
||||||
|
pointer-events: all;
|
||||||
|
cursor: move;
|
||||||
|
|
||||||
|
/* animation: ants 1s linear forwards infinite; */
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
@keyframes ants {
|
||||||
|
0% { stroke-dashoffset: 0; }
|
||||||
|
100% { stroke-dashoffset: -12; }
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
.edge {
|
||||||
|
fill: #aaa;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 250ms ease;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: all;
|
||||||
|
--edge-width: calc(10px / var(--scale, 1));
|
||||||
|
|
||||||
|
@media (max-width: 779px) {
|
||||||
|
--edge-width: calc(20px / var(--scale, 1));
|
||||||
|
fill: rgba(0, 0, 0, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-edge='left'],
|
||||||
|
&[data-edge='right'] {
|
||||||
|
cursor: ew-resize;
|
||||||
|
transform: translate(calc(var(--edge-width, 10px) / -2), 0);
|
||||||
|
width: var(--edge-width, 10px);
|
||||||
|
}
|
||||||
|
&[data-edge='top'],
|
||||||
|
&[data-edge='bottom'] {
|
||||||
|
cursor: ns-resize;
|
||||||
|
transform: translate(0, calc(var(--edge-width, 10px) / -2));
|
||||||
|
height: var(--edge-width, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
opacity: 0.1;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner {
|
||||||
|
r: calc(4 / var(--scale, 1));
|
||||||
|
stroke-width: calc(4 / var(--scale, 1));
|
||||||
|
stroke: rgba(225, 225, 225, 0.01);
|
||||||
|
fill: white;
|
||||||
|
shape-rendering: geometricprecision;
|
||||||
|
pointer-events: all;
|
||||||
|
transition: fill 250ms ease, stroke 250ms ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
stroke: rgba(225, 225, 225, 0.5);
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 779px) {
|
||||||
|
r: calc(10 / var(--scale, 1));
|
||||||
|
stroke-width: calc(2 / var(--scale, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-edge='left,top'] {
|
||||||
|
cursor: nw-resize;
|
||||||
|
}
|
||||||
|
&[data-edge='right,top'] {
|
||||||
|
cursor: ne-resize;
|
||||||
|
}
|
||||||
|
&[data-edge='right,bottom'] {
|
||||||
|
cursor: se-resize;
|
||||||
|
}
|
||||||
|
&[data-edge='left,bottom'] {
|
||||||
|
cursor: sw-resize;
|
||||||
|
}
|
||||||
|
}
|
||||||
617
src/client/lazy-app/Compress/Transform/index.tsx
Normal file
617
src/client/lazy-app/Compress/Transform/index.tsx
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
import {
|
||||||
|
h,
|
||||||
|
Component,
|
||||||
|
Fragment,
|
||||||
|
createRef,
|
||||||
|
FunctionComponent,
|
||||||
|
ComponentChildren,
|
||||||
|
} from 'preact';
|
||||||
|
import type {
|
||||||
|
default as PinchZoom,
|
||||||
|
ScaleToOpts,
|
||||||
|
} from '../Output/custom-els/PinchZoom';
|
||||||
|
import '../Output/custom-els/PinchZoom';
|
||||||
|
import * as style from './style.css';
|
||||||
|
import 'add-css:./style.css';
|
||||||
|
import {
|
||||||
|
AddIcon,
|
||||||
|
CheckmarkIcon,
|
||||||
|
CompareIcon,
|
||||||
|
FlipHorizontallyIcon,
|
||||||
|
FlipVerticallyIcon,
|
||||||
|
RemoveIcon,
|
||||||
|
RotateClockwiseIcon,
|
||||||
|
RotateCounterClockwiseIcon,
|
||||||
|
SwapIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
import { cleanSet } from '../../util/clean-modify';
|
||||||
|
import type { SourceImage } from '../../Compress';
|
||||||
|
import { PreprocessorState } from 'client/lazy-app/feature-meta';
|
||||||
|
import Cropper, { CropBox } from './Cropper';
|
||||||
|
import CanvasImage from '../CanvasImage';
|
||||||
|
import Expander from '../Options/Expander';
|
||||||
|
import Select from '../Options/Select';
|
||||||
|
import Checkbox from '../Options/Checkbox';
|
||||||
|
|
||||||
|
const ROTATE_ORIENTATIONS = [0, 90, 180, 270] as const;
|
||||||
|
|
||||||
|
const cropPresets = {
|
||||||
|
square: {
|
||||||
|
name: 'Square',
|
||||||
|
ratio: 1,
|
||||||
|
},
|
||||||
|
'4:3': {
|
||||||
|
name: '4:3',
|
||||||
|
ratio: 4 / 3,
|
||||||
|
},
|
||||||
|
'16:9': {
|
||||||
|
name: '16:9',
|
||||||
|
ratio: 16 / 9,
|
||||||
|
},
|
||||||
|
'16:10': {
|
||||||
|
name: '16:10',
|
||||||
|
ratio: 16 / 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type CropPresetId = keyof typeof cropPresets;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
source: SourceImage;
|
||||||
|
preprocessorState: PreprocessorState;
|
||||||
|
mobileView: boolean;
|
||||||
|
onCancel?(): void;
|
||||||
|
onSave?(e: { preprocessorState: PreprocessorState }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
scale: number;
|
||||||
|
editingScale: boolean;
|
||||||
|
rotate: typeof ROTATE_ORIENTATIONS[number];
|
||||||
|
// crop: false | CropBox;
|
||||||
|
crop: CropBox;
|
||||||
|
cropPreset: keyof typeof cropPresets | undefined;
|
||||||
|
lockAspect: boolean;
|
||||||
|
flip: PreprocessorState['flip'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const scaleToOpts: ScaleToOpts = {
|
||||||
|
originX: '50%',
|
||||||
|
originY: '50%',
|
||||||
|
relativeTo: 'container',
|
||||||
|
allowChangeEvent: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Transform extends Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
scale: 1,
|
||||||
|
editingScale: false,
|
||||||
|
cropPreset: undefined,
|
||||||
|
lockAspect: false,
|
||||||
|
...this.fromPreprocessorState(this.props.preprocessorState),
|
||||||
|
};
|
||||||
|
pinchZoom = createRef<PinchZoom>();
|
||||||
|
scaleInput = createRef<HTMLInputElement>();
|
||||||
|
|
||||||
|
// static getDerivedStateFromProps({ source, preprocessorState }: Props) {
|
||||||
|
// return {
|
||||||
|
// rotate: preprocessorState.rotate.rotate || 0,
|
||||||
|
// crop: preprocessorState.crop || false,
|
||||||
|
// flip: preprocessorState.flip || { horizontal: false, vertical: false },
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
componentWillReceiveProps(
|
||||||
|
{ source, preprocessorState }: Props,
|
||||||
|
{ crop, cropPreset }: State,
|
||||||
|
) {
|
||||||
|
if (preprocessorState !== this.props.preprocessorState) {
|
||||||
|
this.setState(this.fromPreprocessorState(preprocessorState));
|
||||||
|
}
|
||||||
|
const { width, height } = source.decoded;
|
||||||
|
const cropWidth = width - crop.left - crop.right;
|
||||||
|
const cropHeight = height - crop.top - crop.bottom;
|
||||||
|
for (const [id, preset] of Object.entries(cropPresets)) {
|
||||||
|
if (cropHeight * preset.ratio === cropWidth) {
|
||||||
|
if (cropPreset !== id) {
|
||||||
|
this.setState({ cropPreset: id as CropPresetId });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fromPreprocessorState(preprocessorState?: PreprocessorState) {
|
||||||
|
const state: Pick<State, 'rotate' | 'crop' | 'flip'> = {
|
||||||
|
rotate: preprocessorState ? preprocessorState.rotate.rotate : 0,
|
||||||
|
crop: Object.assign(
|
||||||
|
{
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
(preprocessorState && preprocessorState.crop) || {},
|
||||||
|
),
|
||||||
|
flip: Object.assign(
|
||||||
|
{
|
||||||
|
horizontal: false,
|
||||||
|
vertical: false,
|
||||||
|
},
|
||||||
|
(preprocessorState && preprocessorState.flip) || {},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private save = () => {
|
||||||
|
const { preprocessorState, onSave } = this.props;
|
||||||
|
const { rotate, crop, flip } = this.state;
|
||||||
|
|
||||||
|
let newState = cleanSet(preprocessorState, 'rotate.rotate', rotate);
|
||||||
|
newState = cleanSet(newState, 'crop', crop);
|
||||||
|
newState = cleanSet(newState, 'flip', flip);
|
||||||
|
|
||||||
|
console.log('u', JSON.parse(JSON.stringify(newState)));
|
||||||
|
|
||||||
|
if (onSave) onSave({ preprocessorState: newState });
|
||||||
|
};
|
||||||
|
|
||||||
|
private cancel = () => {
|
||||||
|
const { onCancel, onSave } = this.props;
|
||||||
|
if (onCancel) onCancel();
|
||||||
|
else if (onSave)
|
||||||
|
onSave({ preprocessorState: this.props.preprocessorState });
|
||||||
|
};
|
||||||
|
|
||||||
|
// private fitToViewport = () => {
|
||||||
|
// const pinchZoom = this.pinchZoom.current;
|
||||||
|
// const img = this.props.source?.preprocessed;
|
||||||
|
// if (!img || !pinchZoom) return;
|
||||||
|
// const scale = Number(Math.min(
|
||||||
|
// (window.innerWidth - 20) / img.width,
|
||||||
|
// (window.innerHeight - 20) / img.height
|
||||||
|
// ).toFixed(2));
|
||||||
|
// pinchZoom.scaleTo(Number(scale.toFixed(2)), { allowChangeEvent: true });
|
||||||
|
// this.recenter();
|
||||||
|
// };
|
||||||
|
|
||||||
|
// private recenter = () => {
|
||||||
|
// const pinchZoom = this.pinchZoom.current;
|
||||||
|
// const img = this.props.source?.preprocessed;
|
||||||
|
// if (!img || !pinchZoom) return;
|
||||||
|
// pinchZoom.setTransform({
|
||||||
|
// x: (img.width - img.width * pinchZoom.scale) / 2,
|
||||||
|
// y: (img.height - img.height * pinchZoom.scale) / 2,
|
||||||
|
// allowChangeEvent: true
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
|
||||||
|
private zoomIn = () => {
|
||||||
|
if (!this.pinchZoom.current) throw Error('Missing pinch-zoom element');
|
||||||
|
this.pinchZoom.current.scaleTo(this.state.scale * 1.25, scaleToOpts);
|
||||||
|
};
|
||||||
|
|
||||||
|
private zoomOut = () => {
|
||||||
|
if (!this.pinchZoom.current) throw Error('Missing pinch-zoom element');
|
||||||
|
this.pinchZoom.current.scaleTo(this.state.scale / 1.25, scaleToOpts);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onScaleValueFocus = () => {
|
||||||
|
this.setState({ editingScale: true }, () => {
|
||||||
|
if (this.scaleInput.current) {
|
||||||
|
// 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.current).transform;
|
||||||
|
this.scaleInput.current.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.pinchZoom.current) throw Error('Missing pinch-zoom element');
|
||||||
|
|
||||||
|
this.pinchZoom.current.scaleTo(percent / 100, scaleToOpts);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPinchZoomChange = () => {
|
||||||
|
if (!this.pinchZoom.current) throw Error('Missing pinch-zoom element');
|
||||||
|
this.setState({
|
||||||
|
scale: this.pinchZoom.current.scale,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private onCropChange = (crop: CropBox) => {
|
||||||
|
this.setState({ crop });
|
||||||
|
};
|
||||||
|
|
||||||
|
private onCropPresetChange = (event: Event) => {
|
||||||
|
const { value } = event.target as HTMLSelectElement;
|
||||||
|
// @ts-ignore-next
|
||||||
|
const cropPreset = cropPresets[value];
|
||||||
|
this.setState({
|
||||||
|
cropPreset,
|
||||||
|
lockAspect: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private swapCropDimensions = () => {
|
||||||
|
const { width, height } = this.props.source.decoded;
|
||||||
|
let { left, right, top, bottom } = this.state.crop;
|
||||||
|
const cropWidth = width - left - right;
|
||||||
|
const cropHeight = height - top - bottom;
|
||||||
|
const centerX = left - right;
|
||||||
|
const centerY = top - bottom;
|
||||||
|
const crop = {
|
||||||
|
top: (width - cropWidth) / 2 + centerY / 2,
|
||||||
|
bottom: (width - cropWidth) / 2 - centerY / 2,
|
||||||
|
left: (height - cropHeight) / 2 + centerX / 2,
|
||||||
|
right: (height - cropHeight) / 2 - centerX / 2,
|
||||||
|
};
|
||||||
|
this.setCrop(crop);
|
||||||
|
};
|
||||||
|
|
||||||
|
private setCrop(crop: CropBox) {
|
||||||
|
if (crop.top < 0) {
|
||||||
|
crop.bottom += crop.top;
|
||||||
|
crop.top = 0;
|
||||||
|
}
|
||||||
|
if (crop.bottom < 0) {
|
||||||
|
crop.top += crop.bottom;
|
||||||
|
crop.bottom = 0;
|
||||||
|
}
|
||||||
|
if (crop.left < 0) {
|
||||||
|
crop.right += crop.left;
|
||||||
|
crop.left = 0;
|
||||||
|
}
|
||||||
|
if (crop.right < 0) {
|
||||||
|
crop.left += crop.right;
|
||||||
|
crop.right = 0;
|
||||||
|
}
|
||||||
|
this.setState({ crop });
|
||||||
|
}
|
||||||
|
|
||||||
|
// yeah these could just += 90
|
||||||
|
private rotateClockwise = () => {
|
||||||
|
let { rotate, crop } = this.state;
|
||||||
|
this.setState({
|
||||||
|
rotate: ((rotate + 90) % 360) as typeof ROTATE_ORIENTATIONS[number],
|
||||||
|
crop: {
|
||||||
|
top: crop.left,
|
||||||
|
left: crop.bottom,
|
||||||
|
bottom: crop.right,
|
||||||
|
right: crop.top,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private rotateCounterClockwise = () => {
|
||||||
|
let { rotate, crop } = this.state;
|
||||||
|
this.setState({
|
||||||
|
rotate: (rotate
|
||||||
|
? rotate - 90
|
||||||
|
: 270) as typeof ROTATE_ORIENTATIONS[number],
|
||||||
|
crop: {
|
||||||
|
top: crop.right,
|
||||||
|
right: crop.bottom,
|
||||||
|
bottom: crop.left,
|
||||||
|
left: crop.top,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private flipHorizontally = () => {
|
||||||
|
const { horizontal, vertical } = this.state.flip;
|
||||||
|
this.setState({ flip: { horizontal: !horizontal, vertical } });
|
||||||
|
};
|
||||||
|
|
||||||
|
private flipVertically = () => {
|
||||||
|
const { horizontal, vertical } = this.state.flip;
|
||||||
|
this.setState({ flip: { horizontal, vertical: !vertical } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// private update = (event: Event) => {
|
||||||
|
// const { name, value } = event.target as HTMLInputElement;
|
||||||
|
// const state = cleanSet(this.state, name, value);
|
||||||
|
// this.setState(state);
|
||||||
|
// };
|
||||||
|
|
||||||
|
private toggleLockAspect = () => {
|
||||||
|
this.setState({ lockAspect: !this.state.lockAspect });
|
||||||
|
};
|
||||||
|
|
||||||
|
private setCropWidth = (
|
||||||
|
event: preact.JSX.TargetedEvent<HTMLInputElement, Event>,
|
||||||
|
) => {
|
||||||
|
const { width, height } = this.props.source.decoded;
|
||||||
|
const newWidth = Math.min(width, parseInt(event.currentTarget.value, 10));
|
||||||
|
let { top, right, bottom, left } = this.state.crop;
|
||||||
|
const aspect = (width - left - right) / (height - top - bottom);
|
||||||
|
right = width - newWidth - left;
|
||||||
|
if (this.state.lockAspect) {
|
||||||
|
const newHeight = newWidth / aspect;
|
||||||
|
if (newHeight > height) return;
|
||||||
|
bottom = height - newHeight - top;
|
||||||
|
}
|
||||||
|
this.setCrop({ top, right, bottom, left });
|
||||||
|
};
|
||||||
|
|
||||||
|
private setCropHeight = (
|
||||||
|
event: preact.JSX.TargetedEvent<HTMLInputElement, Event>,
|
||||||
|
) => {
|
||||||
|
const { width, height } = this.props.source.decoded;
|
||||||
|
const newHeight = Math.min(height, parseInt(event.currentTarget.value, 10));
|
||||||
|
let { top, right, bottom, left } = this.state.crop;
|
||||||
|
const aspect = (width - left - right) / (height - top - bottom);
|
||||||
|
bottom = height - newHeight - top;
|
||||||
|
if (this.state.lockAspect) {
|
||||||
|
const newWidth = newHeight * aspect;
|
||||||
|
if (newWidth > width) return;
|
||||||
|
right = width - newWidth - left;
|
||||||
|
}
|
||||||
|
this.setCrop({ top, right, bottom, left });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
// };
|
||||||
|
|
||||||
|
render(
|
||||||
|
{ mobileView, source }: Props,
|
||||||
|
{ scale, editingScale, rotate, flip, crop, cropPreset, lockAspect }: State,
|
||||||
|
) {
|
||||||
|
const image = source.decoded;
|
||||||
|
|
||||||
|
const width = source.decoded.width - crop.left - crop.right;
|
||||||
|
const height = source.decoded.height - crop.top - crop.bottom;
|
||||||
|
|
||||||
|
let transform =
|
||||||
|
`rotate(${rotate}deg) ` +
|
||||||
|
`scale(${flip.horizontal ? -1 : 1}, ${flip.vertical ? -1 : 1})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<CancelButton onClick={this.cancel} />
|
||||||
|
<SaveButton onClick={this.save} />
|
||||||
|
|
||||||
|
<div class={style.transform}>
|
||||||
|
<pinch-zoom
|
||||||
|
class={style.pinchZoom}
|
||||||
|
onChange={this.onPinchZoomChange}
|
||||||
|
ref={this.pinchZoom}
|
||||||
|
>
|
||||||
|
{/* <Backdrop width={image.width} height={image.height} /> */}
|
||||||
|
<div
|
||||||
|
class={style.wrap}
|
||||||
|
style={{
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CanvasImage
|
||||||
|
class={style.pinchTarget}
|
||||||
|
image={image}
|
||||||
|
style={{ transform }}
|
||||||
|
/>
|
||||||
|
{crop && (
|
||||||
|
<Cropper
|
||||||
|
size={{ width: image.width, height: image.height }}
|
||||||
|
scale={scale}
|
||||||
|
lockAspect={lockAspect}
|
||||||
|
crop={crop}
|
||||||
|
onChange={this.onCropChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</pinch-zoom>
|
||||||
|
</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={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>
|
||||||
|
|
||||||
|
<div class={style.options}>
|
||||||
|
<h3 class={style.optionsTitle}>Modify Source</h3>
|
||||||
|
|
||||||
|
<div class={style.optionsSection}>
|
||||||
|
<h4 class={style.optionsSectionTitle}>Crop</h4>
|
||||||
|
<div class={style.optionOneCell}>
|
||||||
|
<Select
|
||||||
|
large
|
||||||
|
value={cropPreset}
|
||||||
|
onChange={this.onCropPresetChange}
|
||||||
|
>
|
||||||
|
<option value="">Custom</option>
|
||||||
|
{Object.entries(cropPresets).map(([type, preset]) => (
|
||||||
|
<option value={type}>{preset.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<label class={style.optionCheckbox}>
|
||||||
|
<Checkbox checked={lockAspect} onClick={this.toggleLockAspect} />
|
||||||
|
Lock aspect-ratio
|
||||||
|
</label>
|
||||||
|
<div class={style.optionsDimensions}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="width"
|
||||||
|
value={width}
|
||||||
|
title="Crop width"
|
||||||
|
onInput={this.setCropWidth}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class={style.optionsButton}
|
||||||
|
title="swap"
|
||||||
|
onClick={this.swapCropDimensions}
|
||||||
|
>
|
||||||
|
<SwapIcon />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="height"
|
||||||
|
value={height}
|
||||||
|
title="Crop height"
|
||||||
|
onInput={this.setCropHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={style.optionButtonRow}>
|
||||||
|
Flip
|
||||||
|
<button
|
||||||
|
class={style.optionsButton}
|
||||||
|
data-active={flip.vertical}
|
||||||
|
title="Flip vertically"
|
||||||
|
onClick={this.flipVertically}
|
||||||
|
>
|
||||||
|
<FlipVerticallyIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={style.optionsButton}
|
||||||
|
data-active={flip.horizontal}
|
||||||
|
title="Flip horizontally"
|
||||||
|
onClick={this.flipHorizontally}
|
||||||
|
>
|
||||||
|
<FlipHorizontallyIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={style.optionButtonRow}>
|
||||||
|
Rotate
|
||||||
|
<button
|
||||||
|
class={style.optionsButton}
|
||||||
|
title="Rotate clockwise"
|
||||||
|
onClick={this.rotateClockwise}
|
||||||
|
>
|
||||||
|
<RotateClockwiseIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={style.optionsButton}
|
||||||
|
title="Rotate counter-clockwise"
|
||||||
|
onClick={this.rotateCounterClockwise}
|
||||||
|
>
|
||||||
|
<RotateCounterClockwiseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CancelButton = ({ onClick }: { onClick: () => void }) => (
|
||||||
|
<button class={style.cancel} onClick={onClick}>
|
||||||
|
<svg viewBox="0 0 80 80" width="80" height="80">
|
||||||
|
<path d="M8.06 40.98c-.53-7.1 4.05-14.52 9.98-19.1s13.32-6.35 22.13-6.43c8.84-.12 19.12 1.51 24.4 7.97s5.6 17.74 1.68 26.97c-3.89 9.26-11.97 16.45-20.46 18-8.43 1.55-17.28-2.62-24.5-8.08S8.54 48.08 8.07 40.98z" />
|
||||||
|
</svg>
|
||||||
|
<CompareIcon class={style.icon} />
|
||||||
|
<span>Cancel</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SaveButton = ({ onClick }: { onClick: () => void }) => (
|
||||||
|
<button class={style.save} onClick={onClick}>
|
||||||
|
<svg viewBox="0 0 89 87" width="89" height="87">
|
||||||
|
<path
|
||||||
|
fill="#0c99ff"
|
||||||
|
opacity=".7"
|
||||||
|
d="M27.3 71.9c-8-4-15.6-12.3-16.9-21-1.2-8.7 4-17.8 10.5-26s14.4-15.6 24-16 21.2 6 28.6 16.5c7.4 10.5 10.8 25 6.6 34S64.1 71.7 54 73.5c-10.2 2-18.7 2.3-26.7-1.6z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#0c99ff"
|
||||||
|
opacity=".7"
|
||||||
|
d="M14.6 24.8c4.3-7.8 13-15 21.8-15.7 8.7-.8 17.5 4.8 25.4 11.8 7.8 6.9 14.8 15.2 14.8 24.9s-7.2 20.7-18 27.6c-10.9 6.8-25.6 9.5-34.3 4.8S13 61.6 11.6 51.4c-1.3-10.3-1.3-18.8 3-26.6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<CheckmarkIcon class={style.icon} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface BackdropProps {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Backdrop extends Component<BackdropProps> {
|
||||||
|
shouldComponentUpdate({ width, height }: BackdropProps) {
|
||||||
|
return width !== this.props.width || height !== this.props.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @TODO this could at least use clip-path */
|
||||||
|
render({ width, height }: BackdropProps) {
|
||||||
|
const transform =
|
||||||
|
`transform-origin: 50% 50%; transform: translate(var(--x), var(--y)) ` +
|
||||||
|
`translate(-${width / 2}px, -${height / 2}px) ` +
|
||||||
|
`scale(calc(var(--scale, 1) * 0.99999));`;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
class={style.backdrop}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
shape-rendering="optimizeSpeed"
|
||||||
|
>
|
||||||
|
<mask id="bgmask">
|
||||||
|
<rect width="100%" height="100%" fill="white" />
|
||||||
|
<rect
|
||||||
|
style={transform}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
x="50%"
|
||||||
|
y="50%"
|
||||||
|
fill="black"
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
<rect
|
||||||
|
class={style.backdropArea}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
mask="url(#bgmask)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
351
src/client/lazy-app/Compress/Transform/style.css
Normal file
351
src/client/lazy-app/Compress/Transform/style.css
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
.transform {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
/*
|
||||||
|
& > canvas {
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: none !important;
|
||||||
|
will-change: initial !important;
|
||||||
|
contain: strict;
|
||||||
|
|
||||||
|
& * {
|
||||||
|
contain: strict;
|
||||||
|
}
|
||||||
|
/* background: rgba(255, 0, 0, 0.5); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdropArea {
|
||||||
|
fill: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinch-zoom {
|
||||||
|
composes: abs-fill from global;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel,
|
||||||
|
.save {
|
||||||
|
composes: unbutton from global;
|
||||||
|
position: absolute;
|
||||||
|
padding: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
grid-area: 1/1/1/1;
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* @TODO use grid */
|
||||||
|
.cancel {
|
||||||
|
fill: rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
& > svg:not(.icon) {
|
||||||
|
display: block;
|
||||||
|
margin: -8px 0;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 28px;
|
||||||
|
top: 22px;
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
font-size: 80%;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
fill: rgba(0, 0, 0, 0.9);
|
||||||
|
|
||||||
|
& > span {
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
bottom: 78px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
max-width: 250px;
|
||||||
|
margin: 0;
|
||||||
|
width: calc(100% - 60px);
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
align-self: end;
|
||||||
|
border-radius: var(--options-radius) 0 0 var(--options-radius);
|
||||||
|
animation: slideInFromRight 500ms ease-out forwards 1;
|
||||||
|
--horizontal-padding: 15px;
|
||||||
|
--main-theme-color: var(--blue);
|
||||||
|
}
|
||||||
|
@keyframes slideInFromRight {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-title {
|
||||||
|
background-color: var(--main-theme-color);
|
||||||
|
color: var(--dark-text);
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px var(--horizontal-padding);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
border-bottom: 1px solid var(--off-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-section {
|
||||||
|
padding: 5px 0;
|
||||||
|
background: var(--off-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-section-title {
|
||||||
|
font: inherit;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px var(--horizontal-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-base {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7em;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px var(--horizontal-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-button {
|
||||||
|
composes: unbutton from global;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--dark-gray);
|
||||||
|
color: var(--white);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
background-color: var(--off-black);
|
||||||
|
border-color: var(--med-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active] {
|
||||||
|
background-color: var(--dark-gray);
|
||||||
|
border-color: var(--med-gray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-dimensions {
|
||||||
|
composes: option-base;
|
||||||
|
grid-template-columns: 1fr 0fr 1fr;
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
font: inherit;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-one-cell {
|
||||||
|
composes: option-base;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-button-row {
|
||||||
|
composes: option-base;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-checkbox {
|
||||||
|
composes: option-base;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zoom controls */
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 9px 84px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
& > :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: rgba(29, 29, 29, 0.92);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.67);
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
height: 39px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
/*
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
height: 39px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
/* box-shadow: 0 0 0 2px var(--hot-pink); */
|
||||||
|
box-shadow: 0 0 0 2px #fff;
|
||||||
|
outline: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(50, 50, 50, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(72, 72, 72, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom {
|
||||||
|
cursor: text;
|
||||||
|
width: 7rem;
|
||||||
|
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 #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span.zoom {
|
||||||
|
color: #939393;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
input.zoom {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
letter-spacing: 0.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-indent: 3px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-value {
|
||||||
|
margin: 0 3px 0 0;
|
||||||
|
padding: 0 2px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
letter-spacing: 0.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px dashed #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons-no-wrap {
|
||||||
|
display: flex;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import WorkerBridge from '../worker-bridge';
|
|||||||
import { resize } from 'features/processors/resize/client';
|
import { resize } from 'features/processors/resize/client';
|
||||||
import type SnackBarElement from 'shared/custom-els/snack-bar';
|
import type SnackBarElement from 'shared/custom-els/snack-bar';
|
||||||
import { CopyAcrossIconProps, ExpandIcon } from '../icons';
|
import { CopyAcrossIconProps, ExpandIcon } from '../icons';
|
||||||
|
import Transform from './Transform';
|
||||||
|
|
||||||
export type OutputType = EncoderType | 'identity';
|
export type OutputType = EncoderType | 'identity';
|
||||||
|
|
||||||
@@ -68,6 +69,8 @@ interface State {
|
|||||||
sides: [Side, Side];
|
sides: [Side, Side];
|
||||||
/** Source image load */
|
/** Source image load */
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
/** Showing preprocessor transformations modal */
|
||||||
|
transform: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
mobileView: boolean;
|
mobileView: boolean;
|
||||||
altBackground: boolean;
|
altBackground: boolean;
|
||||||
@@ -126,13 +129,18 @@ async function preprocessImage(
|
|||||||
): Promise<ImageData> {
|
): Promise<ImageData> {
|
||||||
assertSignal(signal);
|
assertSignal(signal);
|
||||||
let processedData = data;
|
let processedData = data;
|
||||||
|
const { rotate, flip, crop } = preprocessorState;
|
||||||
|
|
||||||
if (preprocessorState.rotate.rotate !== 0) {
|
if (rotate.rotate !== 0) {
|
||||||
processedData = await workerBridge.rotate(
|
processedData = await workerBridge.rotate(signal, processedData, rotate);
|
||||||
signal,
|
}
|
||||||
processedData,
|
|
||||||
preprocessorState.rotate,
|
if (flip && (flip.horizontal || flip.vertical)) {
|
||||||
);
|
processedData = await workerBridge.flip(signal, processedData, flip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (crop && (crop.left || crop.top || crop.right || crop.bottom)) {
|
||||||
|
processedData = await workerBridge.crop(signal, processedData, crop);
|
||||||
}
|
}
|
||||||
|
|
||||||
return processedData;
|
return processedData;
|
||||||
@@ -274,6 +282,9 @@ export default class Compress extends Component<Props, State> {
|
|||||||
state: State = {
|
state: State = {
|
||||||
source: undefined,
|
source: undefined,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
/** @TODO remove this */
|
||||||
|
// transform: true,
|
||||||
|
transform: false,
|
||||||
preprocessorState: defaultPreprocessorState,
|
preprocessorState: defaultPreprocessorState,
|
||||||
sides: [
|
sides: [
|
||||||
{
|
{
|
||||||
@@ -368,6 +379,20 @@ export default class Compress extends Component<Props, State> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private showPreprocessorTransforms = () => {
|
||||||
|
this.setState({ transform: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
private onTransformUpdated = ({
|
||||||
|
preprocessorState,
|
||||||
|
}: { preprocessorState?: PreprocessorState } = {}) => {
|
||||||
|
console.log('onTransformUpdated', preprocessorState);
|
||||||
|
if (preprocessorState) {
|
||||||
|
this.onPreprocessorChange(preprocessorState);
|
||||||
|
}
|
||||||
|
this.setState({ transform: false });
|
||||||
|
};
|
||||||
|
|
||||||
componentWillReceiveProps(nextProps: Props): void {
|
componentWillReceiveProps(nextProps: Props): void {
|
||||||
if (nextProps.file !== this.props.file) {
|
if (nextProps.file !== this.props.file) {
|
||||||
this.sourceFile = nextProps.file;
|
this.sourceFile = nextProps.file;
|
||||||
@@ -798,11 +823,21 @@ export default class Compress extends Component<Props, State> {
|
|||||||
|
|
||||||
render(
|
render(
|
||||||
{ onBack }: Props,
|
{ onBack }: Props,
|
||||||
{ loading, sides, source, mobileView, altBackground, preprocessorState }: State,
|
{
|
||||||
|
loading,
|
||||||
|
sides,
|
||||||
|
source,
|
||||||
|
mobileView,
|
||||||
|
altBackground,
|
||||||
|
transform,
|
||||||
|
preprocessorState,
|
||||||
|
}: State,
|
||||||
) {
|
) {
|
||||||
const [leftSide, rightSide] = sides;
|
const [leftSide, rightSide] = sides;
|
||||||
const [leftImageData, rightImageData] = sides.map((i) => i.data);
|
const [leftImageData, rightImageData] = sides.map((i) => i.data);
|
||||||
|
|
||||||
|
transform = (source && source.decoded && transform) || false;
|
||||||
|
|
||||||
const options = sides.map((side, index) => (
|
const options = sides.map((side, index) => (
|
||||||
<Options
|
<Options
|
||||||
index={index as 0 | 1}
|
index={index as 0 | 1}
|
||||||
@@ -857,8 +892,13 @@ export default class Compress extends Component<Props, State> {
|
|||||||
rightDisplaySettings.processorState.resize.fitMethod === 'contain';
|
rightDisplaySettings.processorState.resize.fitMethod === 'contain';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`${style.compress} ${altBackground ? style.altBackground : ''}`}>
|
<div
|
||||||
|
class={`${style.compress} ${transform ? style.transforming : ''} ${
|
||||||
|
altBackground ? style.altBackground : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Output
|
<Output
|
||||||
|
hidden={transform}
|
||||||
source={source}
|
source={source}
|
||||||
mobileView={mobileView}
|
mobileView={mobileView}
|
||||||
leftCompressed={leftImageData}
|
leftCompressed={leftImageData}
|
||||||
@@ -867,6 +907,7 @@ export default class Compress extends Component<Props, State> {
|
|||||||
rightImgContain={rightImgContain}
|
rightImgContain={rightImgContain}
|
||||||
preprocessorState={preprocessorState}
|
preprocessorState={preprocessorState}
|
||||||
onPreprocessorChange={this.onPreprocessorChange}
|
onPreprocessorChange={this.onPreprocessorChange}
|
||||||
|
onShowPreprocessorTransforms={this.showPreprocessorTransforms}
|
||||||
onToggleBackground={this.toggleBackground}
|
onToggleBackground={this.toggleBackground}
|
||||||
/>
|
/>
|
||||||
<button class={style.back} onClick={onBack}>
|
<button class={style.back} onClick={onBack}>
|
||||||
@@ -903,6 +944,16 @@ export default class Compress extends Component<Props, State> {
|
|||||||
</div>,
|
</div>,
|
||||||
]
|
]
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{transform && (
|
||||||
|
<Transform
|
||||||
|
mobileView={mobileView}
|
||||||
|
source={source!}
|
||||||
|
preprocessorState={preprocessorState!}
|
||||||
|
onSave={this.onTransformUpdated}
|
||||||
|
onCancel={this.onTransformUpdated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,24 @@
|
|||||||
&.alt-background::before {
|
&.alt-background::before {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* transformation is modal and we sweep away the comparison UI */
|
||||||
|
&.transforming {
|
||||||
|
& > .options {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
& > .options + .options {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .back {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.options {
|
.options {
|
||||||
@@ -49,6 +67,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
align-self: end;
|
align-self: end;
|
||||||
grid-area: opts;
|
grid-area: opts;
|
||||||
|
transition: transform 500ms ease;
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
@media (min-width: 600px) {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
|
|||||||
@@ -11,6 +11,50 @@ const Icon = (props: preact.JSX.HTMLAttributes) => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SwapIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M9.01 14H2v2h7.01v3L13 15l-3.99-4zm5.98-1v-3H22V8h-7.01V5L11 9z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FlipVerticallyIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M21 9V7h-2v2zM9 5V3H7v2zM5 21h14a2 2 0 002-2v-4h-2v4H5v-4H3v4a2 2 0 002 2zM3 5h2V3a2 2 0 00-2 2zm20 8v-2H1v2zm-6-8V3h-2v2zM5 9V7H3v2zm8-4V3h-2v2zm8 0a2 2 0 00-2-2v2z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FlipHorizontallyIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M15 21h2v-2h-2zm4-12h2V7h-2zM3 5v14a2 2 0 002 2h4v-2H5V5h4V3H5a2 2 0 00-2 2zm16-2v2h2a2 2 0 00-2-2zm-8 20h2V1h-2zm8-6h2v-2h-2zM15 5h2V3h-2zm4 8h2v-2h-2zm0 8a2 2 0 002-2h-2z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RotateClockwiseIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M16.05 5.34l-5.2-5.2v3.5a9.12 9.12 0 000 18.1v-2.3a6.84 6.84 0 010-13.5v4.47zm5 6.22a9.03 9.03 0 00-1.85-4.44l-1.62 1.62a6.63 6.63 0 011.16 2.82zm-7.91 7.87v2.31a9.05 9.05 0 004.45-1.84l-1.64-1.64a6.6 6.6 0 01-2.81 1.18zm4.44-2.76l1.62 1.61a9.03 9.03 0 001.85-4.44h-2.3a6.73 6.73 0 01-1.17 2.83z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RotateCounterClockwiseIcon = (
|
||||||
|
props: preact.JSX.HTMLAttributes,
|
||||||
|
) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M7.95 5.34l5.19-5.2v3.5a9.12 9.12 0 010 18.1v-2.3a6.84 6.84 0 000-13.5v4.47zm-5 6.22A9.03 9.03 0 014.8 7.12l1.62 1.62a6.63 6.63 0 00-1.17 2.82zm7.9 7.87v2.31A9.05 9.05 0 016.4 19.9l1.65-1.64a6.6 6.6 0 002.8 1.17zm-4.43-2.76L4.8 18.28a9.03 9.03 0 01-1.85-4.44h2.3a6.73 6.73 0 001.17 2.83z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CheckmarkIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M9.76 17.56l-4.55-4.55-1.52 1.52 6.07 6.08 13-13.02-1.51-1.52z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CompareIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<path d="M9.77 1.94h-5.6a2.24 2.24 0 00-2.22 2.25v15.65a2.24 2.24 0 002.24 2.23h5.59v2.24h2.23V-.31H9.78zm0 16.77h-5.6l5.6-6.7zM19.83 1.94h-5.6v2.25h5.6v14.53l-5.6-6.7v10.05h5.6a2.24 2.24 0 002.23-2.23V4.18a2.24 2.24 0 00-2.23-2.24z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
export const DownloadIcon = (props: preact.JSX.HTMLAttributes) => (
|
export const DownloadIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
<Icon {...props}>
|
<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" />
|
<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" />
|
||||||
@@ -37,6 +81,14 @@ export const RotateIcon = (props: preact.JSX.HTMLAttributes) => (
|
|||||||
</Icon>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const MoreIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
|
<Icon {...props}>
|
||||||
|
<circle cx="12" cy="6" r="2" fill="#fff" />
|
||||||
|
<circle cx="12" cy="12" r="2" fill="#fff" />
|
||||||
|
<circle cx="12" cy="18" r="2" fill="#fff" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
export const AddIcon = (props: preact.JSX.HTMLAttributes) => (
|
export const AddIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
<Icon {...props}>
|
<Icon {...props}>
|
||||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||||
|
|||||||
Reference in New Issue
Block a user