forked from external-repos/squoosh
Compare commits
9 Commits
preprocess
...
urls-bug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
886d8d632b | ||
|
|
e2d1adf5e9 | ||
|
|
223da087b2 | ||
|
|
765402404a | ||
|
|
c260f3e280 | ||
|
|
45187c4932 | ||
|
|
9b8d3b1bd8 | ||
|
|
a811b6cf41 | ||
|
|
ba0df8e2ff |
@@ -75,9 +75,7 @@ async function getInputFiles(paths) {
|
|||||||
|
|
||||||
for (const inputPath of paths) {
|
for (const inputPath of paths) {
|
||||||
const files = (await fsp.lstat(inputPath)).isDirectory()
|
const files = (await fsp.lstat(inputPath)).isDirectory()
|
||||||
? (await fsp.readdir(inputPath, { withFileTypes: true }))
|
? (await fsp.readdir(inputPath, {withFileTypes: true})).filter(dirent => dirent.isFile()).map(dirent => path.join(inputPath, dirent.name))
|
||||||
.filter((dirent) => dirent.isFile())
|
|
||||||
.map((dirent) => path.join(inputPath, dirent.name))
|
|
||||||
: [inputPath];
|
: [inputPath];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ export default function entryDataPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
getDependencies(chunks, chunk).map((filename) =>
|
getDependencies(chunks, chunk).map((filename) => fileNameToURL(filename)),
|
||||||
fileNameToURL(filename),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ When you have encoded an image, you normally want to write it to a file.
|
|||||||
This example takes an image that has been encoded as a `jpg` and writes it to a file:
|
This example takes an image that has been encoded as a `jpg` and writes it to a file:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const rawEncodedImage = (await image.encodedWith.mozjpeg).binary;
|
const rawEncodedImage = (await image.encodedWidth.mozjpeg).binary;
|
||||||
|
|
||||||
fs.writeFile('/path/to/new/image.jpg', rawEncodedImage);
|
fs.writeFile('/path/to/new/image.jpg', rawEncodedImage);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright 2020 Google Inc. All Rights Reserved.
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import { relative, join } from 'path';
|
|
||||||
import { promises as fsp } from 'fs';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
import * as ts from 'typescript';
|
|
||||||
import glob from 'glob';
|
|
||||||
import { sync as whichSync } from 'which';
|
|
||||||
|
|
||||||
const globP = promisify(glob);
|
|
||||||
|
|
||||||
const tscPath = whichSync('tsc');
|
|
||||||
|
|
||||||
const extRe = /\.tsx?$/;
|
|
||||||
|
|
||||||
function loadConfig(mainPath) {
|
|
||||||
const fileName = ts.findConfigFile(mainPath, ts.sys.fileExists);
|
|
||||||
if (!fileName) throw Error('tsconfig not found');
|
|
||||||
const text = ts.sys.readFile(fileName);
|
|
||||||
const loadedConfig = ts.parseConfigFileTextToJson(fileName, text).config;
|
|
||||||
const parsedTsConfig = ts.parseJsonConfigFileContent(
|
|
||||||
loadedConfig,
|
|
||||||
ts.sys,
|
|
||||||
process.cwd(),
|
|
||||||
undefined,
|
|
||||||
fileName,
|
|
||||||
);
|
|
||||||
return parsedTsConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function simpleTS(mainPath, { noBuild, watch } = {}) {
|
|
||||||
const config = loadConfig(mainPath);
|
|
||||||
const args = ['-b', mainPath];
|
|
||||||
|
|
||||||
let tsBuildDone;
|
|
||||||
|
|
||||||
async function watchBuiltFiles(rollupContext) {
|
|
||||||
const matches = await globP(config.options.outDir + '/**/*.js');
|
|
||||||
for (const match of matches) rollupContext.addWatchFile(match);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tsBuild(rollupContext) {
|
|
||||||
if (tsBuildDone) {
|
|
||||||
// Watch lists are cleared on each build, so we need to rewatch all the JS files.
|
|
||||||
await watchBuiltFiles(rollupContext);
|
|
||||||
return tsBuildDone;
|
|
||||||
}
|
|
||||||
if (noBuild) {
|
|
||||||
return (tsBuildDone = Promise.resolve());
|
|
||||||
}
|
|
||||||
tsBuildDone = Promise.resolve().then(async () => {
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
const proc = spawn(tscPath, args, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('exit', (code) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
throw Error('TypeScript build failed');
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await watchBuiltFiles(rollupContext);
|
|
||||||
|
|
||||||
if (watch) {
|
|
||||||
tsBuildDone.then(() => {
|
|
||||||
spawn(tscPath, [...args, '--watch', '--preserveWatchOutput'], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return tsBuildDone;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'simple-ts',
|
|
||||||
resolveId(id, importer) {
|
|
||||||
// If there isn't an importer, it's an entry point, so we don't need to resolve it relative
|
|
||||||
// to something.
|
|
||||||
if (!importer) return null;
|
|
||||||
|
|
||||||
const tsResolve = ts.resolveModuleName(
|
|
||||||
id,
|
|
||||||
importer,
|
|
||||||
config.options,
|
|
||||||
ts.sys,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
// It didn't find anything
|
|
||||||
!tsResolve.resolvedModule ||
|
|
||||||
// Or if it's linking to a definition file, it's something in node_modules,
|
|
||||||
// or something local like css.d.ts
|
|
||||||
tsResolve.resolvedModule.extension === '.d.ts'
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return tsResolve.resolvedModule.resolvedFileName;
|
|
||||||
},
|
|
||||||
async load(id) {
|
|
||||||
if (!extRe.test(id)) return null;
|
|
||||||
|
|
||||||
// TypeScript building is deferred until the first TS file load.
|
|
||||||
// This allows prerequisites to happen first,
|
|
||||||
// such as css.d.ts generation in css-plugin.
|
|
||||||
await tsBuild(this);
|
|
||||||
|
|
||||||
// Look for the JS equivalent in the tmp folder
|
|
||||||
const newId = join(
|
|
||||||
config.options.outDir,
|
|
||||||
relative(config.options.rootDir, id),
|
|
||||||
).replace(extRe, '.js');
|
|
||||||
|
|
||||||
return fsp.readFile(newId, { encoding: 'utf8' });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
27
libsquoosh/package-lock.json
generated
27
libsquoosh/package-lock.json
generated
@@ -1125,9 +1125,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
"version": "15.6.1",
|
"version": "15.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.0.1.tgz",
|
||||||
"integrity": "sha512-7EIraBEyRHEe7CH+Fm1XvgqU6uwZN8Q7jppJGcqjROMT29qhAuuOxYB1uEY5UMYQKEmA5D+5tBnhdaPXSsLONA==",
|
"integrity": "sha512-TMkXt0Ck1y0KKsGr9gJtWGjttxlZnnvDtphxUOSd0bfaR6Q1jle+sPvrzNR1urqYTWMinoKvjKfXUGsumaO1PA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@types/resolve": {
|
"@types/resolve": {
|
||||||
@@ -1492,12 +1492,6 @@
|
|||||||
"@types/estree": "*"
|
"@types/estree": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"isexe": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
|
||||||
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"jest-worker": {
|
"jest-worker": {
|
||||||
"version": "26.6.2",
|
"version": "26.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
|
||||||
@@ -1832,12 +1826,6 @@
|
|||||||
"integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
|
"integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"typescript": {
|
|
||||||
"version": "4.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.2.tgz",
|
|
||||||
"integrity": "sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"unicode-canonical-property-names-ecmascript": {
|
"unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",
|
||||||
@@ -1871,15 +1859,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.0.3.tgz",
|
||||||
"integrity": "sha512-d2H/t0eqRNM4w2WvmTdoeIvzAUSpK7JmATB8Nr2lb7nQ9BTIJVjbQ/TRFVEh2gUH1HwclPdoPtfMoFfetXaZnA=="
|
"integrity": "sha512-d2H/t0eqRNM4w2WvmTdoeIvzAUSpK7JmATB8Nr2lb7nQ9BTIJVjbQ/TRFVEh2gUH1HwclPdoPtfMoFfetXaZnA=="
|
||||||
},
|
},
|
||||||
"which": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
|
||||||
"isexe": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"wrappy": {
|
"wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.2.3",
|
"version": "0.2.3",
|
||||||
"description": "A Node library for Squoosh",
|
"description": "A Node library for Squoosh",
|
||||||
"public": true,
|
"public": true,
|
||||||
"main": "./build/index.js",
|
"main": "/build/index.js",
|
||||||
"files": [
|
"files": [
|
||||||
"/build/*"
|
"/build/*"
|
||||||
],
|
],
|
||||||
@@ -22,10 +22,7 @@
|
|||||||
"@rollup/plugin-babel": "^5.3.0",
|
"@rollup/plugin-babel": "^5.3.0",
|
||||||
"@rollup/plugin-commonjs": "^18.0.0",
|
"@rollup/plugin-commonjs": "^18.0.0",
|
||||||
"@rollup/plugin-node-resolve": "^11.2.1",
|
"@rollup/plugin-node-resolve": "^11.2.1",
|
||||||
"@types/node": "^15.6.1",
|
|
||||||
"rollup": "^2.46.0",
|
"rollup": "^2.46.0",
|
||||||
"rollup-plugin-terser": "^7.0.2",
|
"rollup-plugin-terser": "^7.0.2"
|
||||||
"typescript": "^4.1.3",
|
|
||||||
"which": "^2.0.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import resolve from '@rollup/plugin-node-resolve';
|
import resolve from '@rollup/plugin-node-resolve';
|
||||||
import cjs from '@rollup/plugin-commonjs';
|
import cjs from '@rollup/plugin-commonjs';
|
||||||
import simpleTS from './lib/simple-ts';
|
|
||||||
import asset from './lib/asset-plugin.js';
|
import asset from './lib/asset-plugin.js';
|
||||||
import json from './lib/json-plugin.js';
|
import json from './lib/json-plugin.js';
|
||||||
import autojson from './lib/autojson-plugin.js';
|
import autojson from './lib/autojson-plugin.js';
|
||||||
@@ -21,7 +20,6 @@ export default {
|
|||||||
asset(),
|
asset(),
|
||||||
autojson(),
|
autojson(),
|
||||||
json(),
|
json(),
|
||||||
simpleTS('.'),
|
|
||||||
getBabelOutputPlugin({
|
getBabelOutputPlugin({
|
||||||
babelrc: false,
|
babelrc: false,
|
||||||
configFile: false,
|
configFile: false,
|
||||||
|
|||||||
7
libsquoosh/src/image_data.js
Normal file
7
libsquoosh/src/image_data.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default class ImageData {
|
||||||
|
constructor(data, width, height) {
|
||||||
|
this.data = data;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export default class ImageData {
|
|
||||||
readonly data: Uint8ClampedArray;
|
|
||||||
readonly width: number;
|
|
||||||
readonly height: number;
|
|
||||||
|
|
||||||
constructor(data: Uint8ClampedArray, width: number, height: number) {
|
|
||||||
this.data = data;
|
|
||||||
this.width = width;
|
|
||||||
this.height = height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../generic-tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["esnext"],
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"]
|
|
||||||
}
|
|
||||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -6203,9 +6203,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"preact": {
|
"preact": {
|
||||||
"version": "10.5.7",
|
"version": "10.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.5.5.tgz",
|
||||||
"integrity": "sha512-4oEpz75t/0UNcwmcsjk+BIcDdk68oao+7kxcpc1hQPNs2Oo3ZL9xFz8UBf350mxk/VEdD41L5b4l2dE3Ug3RYg==",
|
"integrity": "sha512-5ONLNH1SXMzzbQoExZX4TELemNt+TEDb622xXFNfZngjjM9qtrzseJt+EfiUu4TZ6EJ95X5sE1ES4yqHFSIdhg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"preact-render-to-string": {
|
"preact-render-to-string": {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"postcss-nested": "^4.2.3",
|
"postcss-nested": "^4.2.3",
|
||||||
"postcss-simple-vars": "^5.0.2",
|
"postcss-simple-vars": "^5.0.2",
|
||||||
"postcss-url": "^8.0.0",
|
"postcss-url": "^8.0.0",
|
||||||
"preact": "^10.5.7",
|
"preact": "^10.5.5",
|
||||||
"preact-render-to-string": "^5.1.11",
|
"preact-render-to-string": "^5.1.11",
|
||||||
"prettier": "^2.1.2",
|
"prettier": "^2.1.2",
|
||||||
"rollup": "^2.38.0",
|
"rollup": "^2.38.0",
|
||||||
|
|||||||
@@ -33,23 +33,11 @@ import initialCssPlugin from './lib/initial-css-plugin';
|
|||||||
import serviceWorkerPlugin from './lib/sw-plugin';
|
import serviceWorkerPlugin from './lib/sw-plugin';
|
||||||
import dataURLPlugin from './lib/data-url-plugin';
|
import dataURLPlugin from './lib/data-url-plugin';
|
||||||
import entryDataPlugin, { fileNameToURL } from './lib/entry-data-plugin';
|
import entryDataPlugin, { fileNameToURL } from './lib/entry-data-plugin';
|
||||||
import dedent from 'dedent';
|
|
||||||
|
|
||||||
function resolveFileUrl({ fileName }) {
|
function resolveFileUrl({ fileName }) {
|
||||||
return JSON.stringify(fileNameToURL(fileName));
|
return JSON.stringify(fileNameToURL(fileName));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveImportMetaUrlInStaticBuild(property, { moduleId }) {
|
|
||||||
if (property !== 'url') return;
|
|
||||||
throw new Error(dedent`
|
|
||||||
Attempted to use a \`new URL(..., import.meta.url)\` pattern in ${path.relative(
|
|
||||||
process.cwd(),
|
|
||||||
moduleId,
|
|
||||||
)} for URL that needs to end up in static HTML.
|
|
||||||
This is currently unsupported.
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dir = '.tmp/build';
|
const dir = '.tmp/build';
|
||||||
const staticPath = 'static/c/[name]-[hash][extname]';
|
const staticPath = 'static/c/[name]-[hash][extname]';
|
||||||
const jsPath = staticPath.replace('[extname]', '.js');
|
const jsPath = staticPath.replace('[extname]', '.js');
|
||||||
@@ -112,7 +100,7 @@ export default async function ({ watch }) {
|
|||||||
},
|
},
|
||||||
preserveModules: true,
|
preserveModules: true,
|
||||||
plugins: [
|
plugins: [
|
||||||
{ resolveFileUrl, resolveImportMeta: resolveImportMetaUrlInStaticBuild },
|
{ resolveFileUrl },
|
||||||
clientBundlePlugin(
|
clientBundlePlugin(
|
||||||
{
|
{
|
||||||
external: ['worker_threads'],
|
external: ['worker_threads'],
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { h, Component, createRef } from 'preact';
|
|
||||||
import { drawDataToCanvas } from '../util/canvas';
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
cloneElement,
|
|
||||||
createRef,
|
|
||||||
toChildArray,
|
|
||||||
ComponentChildren,
|
|
||||||
RefObject,
|
|
||||||
} from 'preact';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ComponentChildren;
|
|
||||||
onClick?(e: MouseEvent | KeyboardEvent): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ClickOutsideDetector extends Component<Props> {
|
|
||||||
private _roots: RefObject<Element>[] = [];
|
|
||||||
|
|
||||||
private handleClick = (e: MouseEvent) => {
|
|
||||||
let target = e.target as Node;
|
|
||||||
// check if the click came from within any of our child elements:
|
|
||||||
for (const { current: root } of this._roots) {
|
|
||||||
if (root && (root === target || root.contains(target))) return;
|
|
||||||
}
|
|
||||||
const { onClick } = this.props;
|
|
||||||
if (onClick) onClick(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const { onClick } = this.props;
|
|
||||||
if (onClick) onClick(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
addEventListener('click', this.handleClick, { passive: true });
|
|
||||||
addEventListener('keydown', this.handleKey, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
removeEventListener('click', this.handleClick);
|
|
||||||
removeEventListener('keydown', this.handleKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
render({ children }: Props) {
|
|
||||||
this._roots = [];
|
|
||||||
return toChildArray(children).map((child) => {
|
|
||||||
if (typeof child !== 'object') return child;
|
|
||||||
const ref = createRef();
|
|
||||||
this._roots.push(ref);
|
|
||||||
return cloneElement(child, { ref });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import {
|
|
||||||
h,
|
|
||||||
cloneElement,
|
|
||||||
Component,
|
|
||||||
VNode,
|
|
||||||
createRef,
|
|
||||||
ComponentChildren,
|
|
||||||
ComponentProps,
|
|
||||||
Fragment,
|
|
||||||
render,
|
|
||||||
} from 'preact';
|
|
||||||
import * as style from './style.css';
|
|
||||||
import 'add-css:./style.css';
|
|
||||||
|
|
||||||
type Anchor = 'left' | 'right' | 'top' | 'bottom';
|
|
||||||
type Direction = 'left' | 'right' | 'up' | 'down';
|
|
||||||
|
|
||||||
const has = (haystack: string | string[] | undefined, needle: string) =>
|
|
||||||
Array.isArray(haystack) ? haystack.includes(needle) : haystack === needle;
|
|
||||||
|
|
||||||
interface Props extends Omit<ComponentProps<'aside'>, 'ref'> {
|
|
||||||
showing?: boolean;
|
|
||||||
direction?: Direction | Direction[];
|
|
||||||
anchor?: Anchor;
|
|
||||||
toggle?: VNode;
|
|
||||||
children?: ComponentChildren;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
showing: boolean;
|
|
||||||
hasShown: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class Flyout extends Component<Props, State> {
|
|
||||||
state = {
|
|
||||||
showing: this.props.showing === true,
|
|
||||||
hasShown: this.props.showing === true,
|
|
||||||
};
|
|
||||||
|
|
||||||
private wrap = createRef<HTMLElement>();
|
|
||||||
|
|
||||||
private menu = createRef<HTMLElement>();
|
|
||||||
|
|
||||||
private resizeObserver?: ResizeObserver;
|
|
||||||
|
|
||||||
private shown?: number;
|
|
||||||
|
|
||||||
private dismiss = (event: Event) => {
|
|
||||||
if (this.menu.current && this.menu.current.contains(event.target as Node))
|
|
||||||
return;
|
|
||||||
// prevent toggle buttons from immediately dismissing:
|
|
||||||
if (this.shown && Date.now() - this.shown < 10) return;
|
|
||||||
this.setShowing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
hide = () => {
|
|
||||||
this.setShowing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
show = () => {
|
|
||||||
this.setShowing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
toggle = () => {
|
|
||||||
this.setShowing(!this.state.showing);
|
|
||||||
};
|
|
||||||
|
|
||||||
private setShowing = (showing?: boolean) => {
|
|
||||||
this.shown = Date.now();
|
|
||||||
if (showing) this.setState({ showing: true, hasShown: true });
|
|
||||||
else this.setState({ showing: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
private reposition = () => {
|
|
||||||
const menu = this.menu.current;
|
|
||||||
const wrap = this.wrap.current;
|
|
||||||
if (!menu || !wrap || !this.state.showing) return;
|
|
||||||
const bbox = wrap.getBoundingClientRect();
|
|
||||||
|
|
||||||
const { direction = 'down', anchor = 'right' } = this.props;
|
|
||||||
const { innerWidth, innerHeight } = window;
|
|
||||||
|
|
||||||
const anchorX = has(anchor, 'left') ? bbox.left : bbox.right;
|
|
||||||
|
|
||||||
menu.style.left = menu.style.right = menu.style.top = menu.style.bottom =
|
|
||||||
'';
|
|
||||||
|
|
||||||
if (has(direction, 'left')) {
|
|
||||||
menu.style.right = innerWidth - anchorX + 'px';
|
|
||||||
} else {
|
|
||||||
menu.style.left = anchorX + 'px';
|
|
||||||
}
|
|
||||||
if (has(direction, 'up')) {
|
|
||||||
const anchorY = has(anchor, 'bottom') ? bbox.bottom : bbox.top;
|
|
||||||
menu.style.bottom = innerHeight - anchorY + 'px';
|
|
||||||
} else {
|
|
||||||
const anchorY = has(anchor, 'top') ? bbox.top : bbox.bottom;
|
|
||||||
menu.style.top = anchorY + 'px';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
componentWillReceiveProps({ showing }: Props) {
|
|
||||||
if (showing !== this.props.showing) {
|
|
||||||
this.setShowing(showing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
addEventListener('click', this.dismiss, { passive: true });
|
|
||||||
addEventListener('resize', this.reposition, { passive: true });
|
|
||||||
if (typeof ResizeObserver === 'function' && this.wrap.current) {
|
|
||||||
this.resizeObserver = new ResizeObserver(this.reposition);
|
|
||||||
this.resizeObserver.observe(this.wrap.current);
|
|
||||||
}
|
|
||||||
if (this.props.showing) this.setShowing(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
removeEventListener('click', this.dismiss);
|
|
||||||
removeEventListener('resize', this.reposition);
|
|
||||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
|
||||||
if (this.state.showing && !prevState.showing) {
|
|
||||||
const menu = this.menu.current;
|
|
||||||
if (menu) {
|
|
||||||
this.reposition();
|
|
||||||
|
|
||||||
let toFocus = menu.firstElementChild;
|
|
||||||
for (let child of menu.children) {
|
|
||||||
if (child.hasAttribute('autofocus')) {
|
|
||||||
toFocus = child;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @ts-ignore-next
|
|
||||||
if (toFocus) toFocus.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(
|
|
||||||
{ direction, anchor, toggle, children, ...props }: Props,
|
|
||||||
{ showing }: State,
|
|
||||||
) {
|
|
||||||
const toggleProps = {
|
|
||||||
flyoutOpen: showing,
|
|
||||||
onClick: this.toggle,
|
|
||||||
};
|
|
||||||
|
|
||||||
const directionText = Array.isArray(direction)
|
|
||||||
? direction.join(' ')
|
|
||||||
: direction;
|
|
||||||
const anchorText = Array.isArray(anchor) ? anchor.join(' ') : anchor;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
class={style.wrap}
|
|
||||||
ref={this.wrap}
|
|
||||||
data-flyout-open={showing ? '' : undefined}
|
|
||||||
>
|
|
||||||
{toggle && cloneElement(toggle, toggleProps)}
|
|
||||||
|
|
||||||
{showing &&
|
|
||||||
createPortal(
|
|
||||||
<aside
|
|
||||||
{...props}
|
|
||||||
class={`${style.flyout} ${props.class || props.className || ''}`}
|
|
||||||
ref={this.menu}
|
|
||||||
data-anchor={anchorText}
|
|
||||||
data-direction={directionText}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</aside>,
|
|
||||||
document.body,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// not worth pulling in compat
|
|
||||||
function createPortal(children: ComponentChildren, parent: Element) {
|
|
||||||
return <Portal parent={parent}>{children}</Portal>;
|
|
||||||
}
|
|
||||||
// this is probably overly careful, since it works directly rendering into parent
|
|
||||||
function createPersistentFragment(parent: Element) {
|
|
||||||
const frag = {
|
|
||||||
nodeType: 11,
|
|
||||||
childNodes: [],
|
|
||||||
appendChild: parent.appendChild.bind(parent),
|
|
||||||
insertBefore: parent.insertBefore.bind(parent),
|
|
||||||
removeChild: parent.removeChild.bind(parent),
|
|
||||||
};
|
|
||||||
return (frag as unknown) as Element;
|
|
||||||
}
|
|
||||||
class Portal extends Component<{
|
|
||||||
children: ComponentChildren;
|
|
||||||
parent: Element;
|
|
||||||
}> {
|
|
||||||
root = createPersistentFragment(this.props.parent);
|
|
||||||
componentWillUnmount() {
|
|
||||||
render(null, this.root);
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
render(<Fragment>{this.props.children}</Fragment>, this.root);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
.wrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flyout {
|
|
||||||
display: inline-block;
|
|
||||||
position: fixed;
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
overflow: visible;
|
|
||||||
outline: none;
|
|
||||||
will-change: transform, opacity;
|
|
||||||
animation: menuOpen 350ms ease forwards 1;
|
|
||||||
--flyout-offset-y: -20px;
|
|
||||||
|
|
||||||
&[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-direction*='left'] {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-direction*='up'] {
|
|
||||||
--flyout-offset-y: 20px;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes menuOpen {
|
|
||||||
0% {
|
|
||||||
transform: translateY(var(--flyout-offset-y, 0));
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { h, Component, createRef } from 'preact';
|
import { h, Component } from 'preact';
|
||||||
|
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import 'add-css:./style.css';
|
import 'add-css:./style.css';
|
||||||
@@ -15,10 +15,9 @@ import {
|
|||||||
import Expander from './Expander';
|
import Expander from './Expander';
|
||||||
import Toggle from './Toggle';
|
import Toggle from './Toggle';
|
||||||
import Select from './Select';
|
import Select from './Select';
|
||||||
import Flyout from '../Flyout';
|
|
||||||
import { Options as QuantOptionsComponent } from 'features/processors/quantize/client';
|
import { Options as QuantOptionsComponent } from 'features/processors/quantize/client';
|
||||||
import { Options as ResizeOptionsComponent } from 'features/processors/resize/client';
|
import { Options as ResizeOptionsComponent } from 'features/processors/resize/client';
|
||||||
import { CLIIcon, MoreIcon, SwapIcon } from 'client/lazy-app/icons';
|
import { CLIIcon, SwapIcon } from 'client/lazy-app/icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
index: 0 | 1;
|
index: 0 | 1;
|
||||||
@@ -65,8 +64,6 @@ export default class Options extends Component<Props, State> {
|
|||||||
supportedEncoderMap: undefined,
|
supportedEncoderMap: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
menu = createRef<Flyout>();
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
supportedEncoderMapP.then((supportedEncoderMap) =>
|
supportedEncoderMapP.then((supportedEncoderMap) =>
|
||||||
@@ -113,12 +110,10 @@ export default class Options extends Component<Props, State> {
|
|||||||
|
|
||||||
private onCopyCliClick = () => {
|
private onCopyCliClick = () => {
|
||||||
this.props.onCopyCliClick(this.props.index);
|
this.props.onCopyCliClick(this.props.index);
|
||||||
if (this.menu.current) this.menu.current.hide();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private onCopyToOtherSideClick = () => {
|
private onCopyToOtherSideClick = () => {
|
||||||
this.props.onCopyToOtherSideClick(this.props.index);
|
this.props.onCopyToOtherSideClick(this.props.index);
|
||||||
if (this.menu.current) this.menu.current.hide();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -141,33 +136,23 @@ export default class Options extends Component<Props, State> {
|
|||||||
{!encoderState ? null : (
|
{!encoderState ? null : (
|
||||||
<div>
|
<div>
|
||||||
<h3 class={style.optionsTitle}>
|
<h3 class={style.optionsTitle}>
|
||||||
Edit
|
<div class={style.titleAndButtons}>
|
||||||
<Flyout
|
Edit
|
||||||
ref={this.menu}
|
|
||||||
class={style.menu}
|
|
||||||
direction={['up', 'left']}
|
|
||||||
anchor="right"
|
|
||||||
toggle={
|
|
||||||
<button class={style.titleButton}>
|
|
||||||
<MoreIcon />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
class={style.menuButton}
|
class={style.cliButton}
|
||||||
|
title="Copy npx command"
|
||||||
onClick={this.onCopyCliClick}
|
onClick={this.onCopyCliClick}
|
||||||
>
|
>
|
||||||
<CLIIcon />
|
<CLIIcon />
|
||||||
Copy npx command
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class={style.menuButton}
|
class={style.copyOverButton}
|
||||||
|
title="Copy settings to other side"
|
||||||
onClick={this.onCopyToOtherSideClick}
|
onClick={this.onCopyToOtherSideClick}
|
||||||
>
|
>
|
||||||
<SwapIcon />
|
<SwapIcon />
|
||||||
Copy settings to other side
|
|
||||||
</button>
|
</button>
|
||||||
</Flyout>
|
</div>
|
||||||
</h3>
|
</h3>
|
||||||
<label class={style.sectionEnabler}>
|
<label class={style.sectionEnabler}>
|
||||||
Resize
|
Resize
|
||||||
|
|||||||
@@ -14,21 +14,13 @@
|
|||||||
background-color: var(--main-theme-color);
|
background-color: var(--main-theme-color);
|
||||||
color: var(--header-text-color);
|
color: var(--header-text-color);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 38px;
|
padding: 10px var(--horizontal-padding);
|
||||||
padding: 0 var(--horizontal-padding);
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
border-bottom: 1px solid var(--off-black);
|
border-bottom: 1px solid var(--off-black);
|
||||||
transition: all 300ms ease-in-out;
|
transition: all 300ms ease-in-out;
|
||||||
transition-property: background-color, color;
|
transition-property: background-color, color;
|
||||||
|
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-auto-columns: max-content;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
gap: 0.8rem 0;
|
|
||||||
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -90,63 +82,36 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu {
|
.title-and-buttons {
|
||||||
transform: translateY(-10px);
|
grid-template-columns: 1fr;
|
||||||
|
grid-auto-columns: max-content;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-button {
|
.title-button {
|
||||||
position: relative;
|
|
||||||
left: 10px;
|
|
||||||
composes: unbutton from global;
|
composes: unbutton from global;
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0);
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:active {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
--size: 24px;
|
--size: 20px;
|
||||||
fill: var(--header-text-color);
|
|
||||||
display: block;
|
display: block;
|
||||||
width: var(--size);
|
width: var(--size);
|
||||||
height: var(--size);
|
height: var(--size);
|
||||||
padding: 5px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-button {
|
.cli-button {
|
||||||
display: flex;
|
composes: title-button;
|
||||||
align-items: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 8px 0;
|
|
||||||
background-color: rgba(29, 29, 29, 0.92);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.67);
|
|
||||||
border-radius: 2rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
white-space: nowrap;
|
|
||||||
height: 39px;
|
|
||||||
padding: 0 16px;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
&:hover {
|
svg {
|
||||||
background: rgba(50, 50, 50, 0.92);
|
stroke: var(--header-text-color);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
&:focus {
|
|
||||||
box-shadow: 0 0 0 2px #fff;
|
.copy-over-button {
|
||||||
outline: none;
|
composes: title-button;
|
||||||
z-index: 1;
|
|
||||||
}
|
svg {
|
||||||
|
fill: var(--header-text-color);
|
||||||
& > svg {
|
|
||||||
position: relative;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
margin-right: 12px;
|
|
||||||
color: var(--main-theme-color);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,39 @@
|
|||||||
import { h, createRef, Component, Fragment } from 'preact';
|
import { h, Component, Fragment } from 'preact';
|
||||||
import type PinchZoom from './custom-els/PinchZoom';
|
import type PinchZoom from './custom-els/PinchZoom';
|
||||||
import type { ScaleToOpts } from './custom-els/PinchZoom';
|
import type { ScaleToOpts } from './custom-els/PinchZoom';
|
||||||
import './custom-els/PinchZoom';
|
import './custom-els/PinchZoom';
|
||||||
import './custom-els/TwoUp';
|
import './custom-els/TwoUp';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import 'add-css:./style.css';
|
import 'add-css:./style.css';
|
||||||
import { shallowEqual } from '../../util';
|
import { shallowEqual, drawDataToCanvas } from '../../util';
|
||||||
import {
|
import {
|
||||||
ToggleBackgroundIcon,
|
ToggleBackgroundIcon,
|
||||||
AddIcon,
|
AddIcon,
|
||||||
RemoveIcon,
|
RemoveIcon,
|
||||||
|
ToggleBackgroundActiveIcon,
|
||||||
RotateIcon,
|
RotateIcon,
|
||||||
MoreIcon,
|
|
||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
|
import { twoUpHandle } from './custom-els/TwoUp/styles.css';
|
||||||
import type { PreprocessorState } from '../../feature-meta';
|
import type { PreprocessorState } from '../../feature-meta';
|
||||||
import { cleanSet } from '../../util/clean-modify';
|
import { cleanSet } from '../../util/clean-modify';
|
||||||
import type { SourceImage } from '../../Compress';
|
import type { SourceImage } from '../../Compress';
|
||||||
import { linkRef } from 'shared/prerendered-app/util';
|
import { linkRef } from 'shared/prerendered-app/util';
|
||||||
import Flyout from '../Flyout';
|
|
||||||
import { drawDataToCanvas } from 'client/lazy-app/util/canvas';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source?: SourceImage;
|
source?: SourceImage;
|
||||||
preprocessorState?: PreprocessorState;
|
preprocessorState?: PreprocessorState;
|
||||||
hidden?: boolean;
|
|
||||||
mobileView: boolean;
|
mobileView: boolean;
|
||||||
leftCompressed?: ImageData;
|
leftCompressed?: ImageData;
|
||||||
rightCompressed?: ImageData;
|
rightCompressed?: ImageData;
|
||||||
leftImgContain: boolean;
|
leftImgContain: boolean;
|
||||||
rightImgContain: boolean;
|
rightImgContain: boolean;
|
||||||
onPreprocessorChange?: (newState: PreprocessorState) => void;
|
onPreprocessorChange: (newState: PreprocessorState) => void;
|
||||||
onShowPreprocessorTransforms?: () => void;
|
|
||||||
onToggleBackground?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
scale: number;
|
scale: number;
|
||||||
editingScale: boolean;
|
editingScale: boolean;
|
||||||
altBackground: boolean;
|
altBackground: boolean;
|
||||||
transform: boolean;
|
|
||||||
menuOpen: boolean;
|
|
||||||
smallControls: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scaleToOpts: ScaleToOpts = {
|
const scaleToOpts: ScaleToOpts = {
|
||||||
@@ -56,18 +48,12 @@ export default class Output extends Component<Props, State> {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
editingScale: false,
|
editingScale: false,
|
||||||
altBackground: false,
|
altBackground: false,
|
||||||
transform: false,
|
|
||||||
menuOpen: false,
|
|
||||||
smallControls:
|
|
||||||
typeof matchMedia === 'function' &&
|
|
||||||
matchMedia('(max-width: 859px)').matches,
|
|
||||||
};
|
};
|
||||||
canvasLeft?: HTMLCanvasElement;
|
canvasLeft?: HTMLCanvasElement;
|
||||||
canvasRight?: HTMLCanvasElement;
|
canvasRight?: HTMLCanvasElement;
|
||||||
pinchZoomLeft?: PinchZoom;
|
pinchZoomLeft?: PinchZoom;
|
||||||
pinchZoomRight?: PinchZoom;
|
pinchZoomRight?: PinchZoom;
|
||||||
scaleInput?: HTMLInputElement;
|
scaleInput?: HTMLInputElement;
|
||||||
flyout = createRef<Flyout>();
|
|
||||||
retargetedEvents = new WeakSet<Event>();
|
retargetedEvents = new WeakSet<Event>();
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -89,12 +75,6 @@ export default class Output extends Component<Props, State> {
|
|||||||
if (this.canvasRight && rightDraw) {
|
if (this.canvasRight && rightDraw) {
|
||||||
drawDataToCanvas(this.canvasRight, rightDraw);
|
drawDataToCanvas(this.canvasRight, rightDraw);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof matchMedia === 'function') {
|
|
||||||
matchMedia('(max-width: 859px)').addEventListener('change', (e) =>
|
|
||||||
this.setState({ smallControls: e.matches }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||||
@@ -164,6 +144,12 @@ export default class Output extends Component<Props, State> {
|
|||||||
return props.rightCompressed || (props.source && props.source.preprocessed);
|
return props.rightCompressed || (props.source && props.source.preprocessed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toggleBackground = () => {
|
||||||
|
this.setState({
|
||||||
|
altBackground: !this.state.altBackground,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private zoomIn = () => {
|
private zoomIn = () => {
|
||||||
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
||||||
this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts);
|
this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts);
|
||||||
@@ -174,30 +160,17 @@ export default class Output extends Component<Props, State> {
|
|||||||
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
|
this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts);
|
||||||
};
|
};
|
||||||
|
|
||||||
private fitToViewport = () => {
|
private onRotateClick = () => {
|
||||||
if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element');
|
const { preprocessorState: inputProcessorState } = this.props;
|
||||||
const img = this.props.source?.preprocessed;
|
if (!inputProcessorState) return;
|
||||||
if (!img) return;
|
|
||||||
const scale = Number(
|
|
||||||
Math.min(
|
|
||||||
(window.innerWidth - 20) / img.width,
|
|
||||||
(window.innerHeight - 20) / img.height,
|
|
||||||
).toFixed(2),
|
|
||||||
);
|
|
||||||
this.pinchZoomLeft.scaleTo(Number(scale.toFixed(2)), scaleToOpts);
|
|
||||||
this.recenter();
|
|
||||||
// this.hideMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
private recenter = () => {
|
const newState = cleanSet(
|
||||||
const img = this.props.source?.preprocessed;
|
inputProcessorState,
|
||||||
if (!img || !this.pinchZoomLeft) return;
|
'rotate.rotate',
|
||||||
let scale = this.pinchZoomLeft.scale;
|
(inputProcessorState.rotate.rotate + 90) % 360,
|
||||||
this.pinchZoomLeft.setTransform({
|
);
|
||||||
x: (img.width - img.width * scale) / 2,
|
|
||||||
y: (img.height - img.height * scale) / 2,
|
this.props.onPreprocessorChange(newState);
|
||||||
allowChangeEvent: true,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private onScaleValueFocus = () => {
|
private onScaleValueFocus = () => {
|
||||||
@@ -280,16 +253,8 @@ export default class Output extends Component<Props, State> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
{
|
{ mobileView, leftImgContain, rightImgContain, source }: Props,
|
||||||
source,
|
{ scale, editingScale, altBackground }: State,
|
||||||
mobileView,
|
|
||||||
hidden,
|
|
||||||
leftImgContain,
|
|
||||||
rightImgContain,
|
|
||||||
onShowPreprocessorTransforms,
|
|
||||||
onToggleBackground,
|
|
||||||
}: Props,
|
|
||||||
{ scale, editingScale, smallControls }: State,
|
|
||||||
) {
|
) {
|
||||||
const leftDraw = this.leftDrawable();
|
const leftDraw = this.leftDrawable();
|
||||||
const rightDraw = this.rightDrawable();
|
const rightDraw = this.rightDrawable();
|
||||||
@@ -298,7 +263,9 @@ export default class Output extends Component<Props, State> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div class={style.output} hidden={hidden}>
|
<div
|
||||||
|
class={`${style.output} ${altBackground ? style.altBackground : ''}`}
|
||||||
|
>
|
||||||
<two-up
|
<two-up
|
||||||
legacy-clip-compat
|
legacy-clip-compat
|
||||||
class={style.twoUp}
|
class={style.twoUp}
|
||||||
@@ -324,7 +291,7 @@ export default class Output extends Component<Props, State> {
|
|||||||
style={{
|
style={{
|
||||||
width: originalImage ? originalImage.width : '',
|
width: originalImage ? originalImage.width : '',
|
||||||
height: originalImage ? originalImage.height : '',
|
height: originalImage ? originalImage.height : '',
|
||||||
objectFit: leftImgContain ? 'contain' : undefined,
|
objectFit: leftImgContain ? 'contain' : '',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</pinch-zoom>
|
</pinch-zoom>
|
||||||
@@ -340,16 +307,15 @@ export default class Output extends Component<Props, State> {
|
|||||||
style={{
|
style={{
|
||||||
width: originalImage ? originalImage.width : '',
|
width: originalImage ? originalImage.width : '',
|
||||||
height: originalImage ? originalImage.height : '',
|
height: originalImage ? originalImage.height : '',
|
||||||
objectFit: rightImgContain ? 'contain' : undefined,
|
objectFit: rightImgContain ? 'contain' : '',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</pinch-zoom>
|
</pinch-zoom>
|
||||||
</two-up>
|
</two-up>
|
||||||
</div>
|
</div>
|
||||||
|
<div class={style.controls}>
|
||||||
<div class={style.controls} hidden={hidden}>
|
|
||||||
<div class={style.buttonGroup}>
|
<div class={style.buttonGroup}>
|
||||||
<button class={style.button} onClick={this.zoomOut}>
|
<button class={style.firstButton} onClick={this.zoomOut}>
|
||||||
<RemoveIcon />
|
<RemoveIcon />
|
||||||
</button>
|
</button>
|
||||||
{editingScale ? (
|
{editingScale ? (
|
||||||
@@ -376,34 +342,18 @@ export default class Output extends Component<Props, State> {
|
|||||||
<button class={style.lastButton} onClick={this.zoomIn}>
|
<button class={style.lastButton} onClick={this.zoomIn}>
|
||||||
<AddIcon />
|
<AddIcon />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<Flyout
|
<div class={style.buttonGroup}>
|
||||||
class={style.menu}
|
<button class={style.firstButton} onClick={this.onRotateClick}>
|
||||||
showing={hidden ? false : undefined}
|
<RotateIcon />
|
||||||
anchor="right"
|
</button>
|
||||||
direction={smallControls ? ['down', 'left'] : 'up'}
|
<button class={style.lastButton} onClick={this.toggleBackground}>
|
||||||
toggle={
|
{altBackground ? (
|
||||||
<button class={`${style.button} ${style.moreButton}`}>
|
<ToggleBackgroundActiveIcon />
|
||||||
<MoreIcon />
|
) : (
|
||||||
</button>
|
<ToggleBackgroundIcon />
|
||||||
}
|
)}
|
||||||
>
|
</button>
|
||||||
<button
|
|
||||||
class={style.button}
|
|
||||||
onClick={onShowPreprocessorTransforms}
|
|
||||||
>
|
|
||||||
<RotateIcon /> Rotate & Transform
|
|
||||||
</button>
|
|
||||||
<button class={style.button} onClick={this.fitToViewport}>
|
|
||||||
Fit to viewport
|
|
||||||
</button>
|
|
||||||
<button class={style.button} onClick={this.recenter}>
|
|
||||||
Re-center
|
|
||||||
</button>
|
|
||||||
<button class={style.button} onClick={onToggleBackground}>
|
|
||||||
<ToggleBackgroundIcon /> Change canvas color
|
|
||||||
</button>
|
|
||||||
</Flyout>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
.output {
|
.output {
|
||||||
display: contents;
|
display: contents;
|
||||||
|
|
||||||
&[hidden] {
|
&::before {
|
||||||
display: none;
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #000;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 500ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.alt-background::before {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,21 +42,16 @@
|
|||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
contain: content;
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
padding: 9px 66px;
|
padding: 9px 66px;
|
||||||
position: relative;
|
position: relative;
|
||||||
/* Had to disable containment because of the overflow menu. */
|
|
||||||
/*
|
|
||||||
contain: content;
|
|
||||||
overflow: hidden;
|
|
||||||
*/
|
|
||||||
transition: transform 500ms ease;
|
|
||||||
|
|
||||||
/* Allow clicks to fall through to the pinch zoom area */
|
/* Allow clicks to fall through to the pinch zoom area */
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -55,34 +62,13 @@
|
|||||||
grid-area: viewportOpts;
|
grid-area: viewportOpts;
|
||||||
align-self: end;
|
align-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[hidden] {
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateY(-200%);
|
|
||||||
|
|
||||||
@media (min-width: 860px) {
|
|
||||||
transform: translateY(200%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-group {
|
.button-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
margin: 0 3px;
|
||||||
& > :not(:first-child) {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > :not(:nth-last-child(2)) {
|
|
||||||
margin-right: 0;
|
|
||||||
border-right-width: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button,
|
.button,
|
||||||
@@ -90,10 +76,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 4px;
|
|
||||||
background-color: rgba(29, 29, 29, 0.92);
|
background-color: rgba(29, 29, 29, 0.92);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.67);
|
border: 1px solid rgba(0, 0, 0, 0.67);
|
||||||
border-radius: 6px;
|
border-width: 1px 0 1px 1px;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
height: 39px;
|
height: 39px;
|
||||||
@@ -176,64 +161,3 @@ input.zoom {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Three-dot menu */
|
|
||||||
.moreButton {
|
|
||||||
padding: 0 4px;
|
|
||||||
|
|
||||||
& > svg {
|
|
||||||
transform-origin: center;
|
|
||||||
transition: transform 200ms ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls [data-flyout-open] {
|
|
||||||
.moreButton {
|
|
||||||
background: rgba(82, 82, 82, 0.92);
|
|
||||||
|
|
||||||
& > svg {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(50, 50, 50, 0.4);
|
|
||||||
backdrop-filter: blur(2px) contrast(70%);
|
|
||||||
animation: menuShimFadeIn 350ms ease forwards 1;
|
|
||||||
will-change: opacity;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes menuShimFadeIn {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
button {
|
|
||||||
margin: 8px 0;
|
|
||||||
border-radius: 2rem;
|
|
||||||
padding: 0 16px;
|
|
||||||
|
|
||||||
& > svg {
|
|
||||||
position: relative;
|
|
||||||
left: -6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #fff;
|
|
||||||
margin: 8px 4px;
|
|
||||||
padding: 10px 0 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
import { h, Component, ComponentChildren, createRef } 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 }[];
|
|
||||||
aspect: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
private root = createRef<SVGSVGElement>();
|
|
||||||
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!this.root.current) return;
|
|
||||||
getComputedStyle(this.root.current);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillReceiveProps({ crop }: Props, nextState: State) {
|
|
||||||
const current = nextState.crop || this.state.crop;
|
|
||||||
if (crop !== this.props.crop && !shallowEqual(crop, current)) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
|
|
||||||
let aspect;
|
|
||||||
const edges = edgeAttr.split(/ *, */) as Edge[];
|
|
||||||
if (this.props.lockAspect) {
|
|
||||||
if (edges.length === 1) return;
|
|
||||||
const { size } = this.props;
|
|
||||||
const oldCrop = this.state.crop;
|
|
||||||
aspect =
|
|
||||||
(size.width - oldCrop.left - oldCrop.right) /
|
|
||||||
(size.height - oldCrop.top - oldCrop.bottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pointers.set(event.pointerId, {
|
|
||||||
x: event.x,
|
|
||||||
y: event.y,
|
|
||||||
edges: edges.map((edge) => ({ edge, value: this.state.crop[edge] })),
|
|
||||||
aspect,
|
|
||||||
});
|
|
||||||
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 scale = this.props.scale || 1;
|
|
||||||
let dx = (event.x - down.x) / scale;
|
|
||||||
let dy = (event.y - down.y) / scale;
|
|
||||||
|
|
||||||
if (down.aspect && down.edges.length === 2) {
|
|
||||||
const dir = (dx + dy) / 2;
|
|
||||||
dx = dir * down.aspect;
|
|
||||||
dy = dir / down.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) {
|
|
||||||
if (down.aspect) crop.left = Math.max(0, crop.left);
|
|
||||||
else
|
|
||||||
crop.left = Math.min(
|
|
||||||
crop.left,
|
|
||||||
size.width - oldCrop.right - MIN_SIZE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (crop.right) {
|
|
||||||
if (down.aspect) crop.right = Math.max(0, crop.right);
|
|
||||||
crop.right = Math.min(
|
|
||||||
crop.right,
|
|
||||||
size.width - oldCrop.left - MIN_SIZE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
down.aspect &&
|
|
||||||
(crop.left ?? oldCrop.left) + (crop.right ?? oldCrop.right) >
|
|
||||||
size.width
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
if (down.aspect) crop.top = Math.max(0, crop.top);
|
|
||||||
crop.top = Math.min(
|
|
||||||
crop.top,
|
|
||||||
size.height - oldCrop.bottom - MIN_SIZE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (crop.bottom) {
|
|
||||||
if (down.aspect) crop.bottom = Math.max(0, crop.bottom);
|
|
||||||
crop.bottom = Math.min(
|
|
||||||
crop.bottom,
|
|
||||||
size.height - oldCrop.top - MIN_SIZE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
down.aspect &&
|
|
||||||
(crop.top ?? oldCrop.top) + (crop.bottom ?? oldCrop.bottom) >
|
|
||||||
size.height
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
removeEventListener('keydown', this.onKeyDown);
|
|
||||||
removeEventListener('keyup', this.onKeyUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
render({ size }: 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 s = (x: number) => x.toFixed(3);
|
|
||||||
|
|
||||||
const clip = `polygon(0 0, 0 100%, 100% 100%, 100% 0, 0 0, ${s(x)}px ${s(
|
|
||||||
y,
|
|
||||||
)}px, ${s(x + width)}px ${s(y)}px, ${s(x + width)}px ${s(
|
|
||||||
y + height,
|
|
||||||
)}px, ${s(x)}px ${s(y + height)}px, ${s(x)}px ${s(y)}px)`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
ref={this.root}
|
|
||||||
class={`${style.cropper} ${pan ? style.pan : ''}`}
|
|
||||||
width={size.width + 20}
|
|
||||||
height={size.height + 20}
|
|
||||||
viewBox={`-10 -10 ${size.width + 20} ${size.height + 20}`}
|
|
||||||
onPointerDown={this.onPointerDown}
|
|
||||||
onPointerMove={this.onPointerMove}
|
|
||||||
onPointerUp={this.onPointerUp}
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
class={style.background}
|
|
||||||
width={size.width}
|
|
||||||
height={size.height}
|
|
||||||
clip-path={clip}
|
|
||||||
/>
|
|
||||||
<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%"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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" />
|
|
||||||
<circle class={style.corner} data-edge="right,top" cx="100%" />
|
|
||||||
<circle
|
|
||||||
class={style.corner}
|
|
||||||
data-edge="right,bottom"
|
|
||||||
cx="100%"
|
|
||||||
cy="100%"
|
|
||||||
/>
|
|
||||||
<circle class={style.corner} data-edge="left,bottom" cy="100%" />
|
|
||||||
</Freezer>
|
|
||||||
</svg>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FreezerProps {
|
|
||||||
children: ComponentChildren;
|
|
||||||
}
|
|
||||||
class Freezer extends Component<FreezerProps> {
|
|
||||||
shouldComponentUpdate() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
render({ children }: FreezerProps) {
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
.cropper {
|
|
||||||
position: absolute;
|
|
||||||
left: -10px;
|
|
||||||
top: -10px;
|
|
||||||
right: -10px;
|
|
||||||
bottom: -10px;
|
|
||||||
shape-rendering: crispedges;
|
|
||||||
overflow: visible;
|
|
||||||
contain: layout size;
|
|
||||||
|
|
||||||
&.pan {
|
|
||||||
cursor: grabbing;
|
|
||||||
& * {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > svg {
|
|
||||||
overflow: visible;
|
|
||||||
contain: layout size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.background {
|
|
||||||
pointer-events: none;
|
|
||||||
fill: rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cropbox {
|
|
||||||
fill: none;
|
|
||||||
stroke: white;
|
|
||||||
stroke-width: calc(1.5px / var(--scale, 1));
|
|
||||||
stroke-dasharray: calc(5px / var(--scale, 1)), calc(5px / var(--scale, 1));
|
|
||||||
stroke-dashoffset: 50%;
|
|
||||||
/* Accept pointer input even though this is unpainted transparent */
|
|
||||||
pointer-events: all;
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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(4px / var(--scale, 1));
|
|
||||||
stroke-width: calc(4px / 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
import { h, Component, Fragment, createRef } 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 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: 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>();
|
|
||||||
|
|
||||||
componentWillReceiveProps(
|
|
||||||
{ source, preprocessorState }: Props,
|
|
||||||
{ crop, cropPreset }: State,
|
|
||||||
) {
|
|
||||||
if (preprocessorState !== this.props.preprocessorState) {
|
|
||||||
this.setState(this.fromPreprocessorState(preprocessorState));
|
|
||||||
}
|
|
||||||
const { width, height } = source.decoded;
|
|
||||||
if (crop) {
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (onSave) onSave({ preprocessorState: newState });
|
|
||||||
};
|
|
||||||
|
|
||||||
private cancel = () => {
|
|
||||||
const { onCancel, onSave } = this.props;
|
|
||||||
if (onCancel) onCancel();
|
|
||||||
else if (onSave)
|
|
||||||
onSave({ preprocessorState: this.props.preprocessorState });
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
const cropPreset = value ? (value as keyof typeof cropPresets) : undefined;
|
|
||||||
const crop = { ...this.state.crop };
|
|
||||||
if (cropPreset) {
|
|
||||||
const preset = cropPresets[cropPreset];
|
|
||||||
const { width, height } = this.props.source.decoded;
|
|
||||||
const w = width - crop.left - crop.right;
|
|
||||||
const h = w / preset.ratio;
|
|
||||||
crop.bottom = height - crop.top - h;
|
|
||||||
if (crop.bottom < 0) {
|
|
||||||
crop.top += crop.bottom;
|
|
||||||
crop.bottom = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
crop,
|
|
||||||
cropPreset,
|
|
||||||
lockAspect: !!cropPreset,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (crop.left < 0 || crop.right < 0) crop.left = crop.right = 0;
|
|
||||||
if (crop.top < 0 || crop.bottom < 0) crop.top = crop.bottom = 0;
|
|
||||||
this.setState({ crop });
|
|
||||||
}
|
|
||||||
|
|
||||||
private adjustOffsetAfterRotation = (wideToTall: boolean) => {
|
|
||||||
const image = this.props.source.decoded;
|
|
||||||
let { x, y } = this.pinchZoom.current!;
|
|
||||||
let { width, height } = image;
|
|
||||||
if (wideToTall) {
|
|
||||||
[width, height] = [height, width];
|
|
||||||
}
|
|
||||||
x += (width - height) / 2;
|
|
||||||
y += (height - width) / 2;
|
|
||||||
this.pinchZoom.current!.setTransform({ x, y });
|
|
||||||
};
|
|
||||||
|
|
||||||
private rotateClockwise = () => {
|
|
||||||
let { rotate, crop } = this.state;
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
rotate: ((rotate + 90) % 360) as typeof ROTATE_ORIENTATIONS[number],
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.adjustOffsetAfterRotation(rotate === 0 || rotate === 180);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.setCrop({
|
|
||||||
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],
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.adjustOffsetAfterRotation(rotate === 0 || rotate === 180);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.setCrop({
|
|
||||||
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 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 });
|
|
||||||
};
|
|
||||||
|
|
||||||
render(
|
|
||||||
{ mobileView, source }: Props,
|
|
||||||
{ scale, editingScale, rotate, flip, crop, cropPreset, lockAspect }: State,
|
|
||||||
) {
|
|
||||||
const image = source.decoded;
|
|
||||||
const rotated = rotate === 90 || rotate === 270;
|
|
||||||
|
|
||||||
const displayWidth = rotated ? image.height : image.width;
|
|
||||||
const displayHeight = rotated ? image.width : image.height;
|
|
||||||
|
|
||||||
const width = displayWidth - crop.left - crop.right;
|
|
||||||
const height = displayHeight - crop.top - crop.bottom;
|
|
||||||
|
|
||||||
let transform =
|
|
||||||
`translate(-50%, -50%) ` +
|
|
||||||
`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}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={style.wrap}
|
|
||||||
style={{
|
|
||||||
width: displayWidth,
|
|
||||||
height: displayHeight,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CanvasImage
|
|
||||||
class={style.pinchTarget}
|
|
||||||
image={image}
|
|
||||||
style={{ transform }}
|
|
||||||
/>
|
|
||||||
{crop && (
|
|
||||||
<Cropper
|
|
||||||
size={{ width: displayWidth, height: displayHeight }}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
.transform {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrap {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
/** can't use transform-origin here, pinch-zoom relies on 0,0 */
|
|
||||||
transform: translate(-50%, -50%) translate(var(--x), var(--y))
|
|
||||||
scale(var(--scale));
|
|
||||||
overflow: visible;
|
|
||||||
contain: layout;
|
|
||||||
will-change: initial !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
transform-origin: 50% 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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.7);
|
|
||||||
|
|
||||||
& > 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.7);
|
|
||||||
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);
|
|
||||||
|
|
||||||
/* Hide on mobile (for now) */
|
|
||||||
@media (max-width: 599px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@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;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,13 @@ import * as style from './style.css';
|
|||||||
import 'add-css:./style.css';
|
import 'add-css:./style.css';
|
||||||
import {
|
import {
|
||||||
blobToImg,
|
blobToImg,
|
||||||
|
drawableToImageData,
|
||||||
blobToText,
|
blobToText,
|
||||||
builtinDecode,
|
builtinDecode,
|
||||||
sniffMimeType,
|
sniffMimeType,
|
||||||
canDecodeImageType,
|
canDecodeImageType,
|
||||||
abortable,
|
abortable,
|
||||||
assertSignal,
|
assertSignal,
|
||||||
shallowEqual,
|
|
||||||
ImageMimeTypes,
|
ImageMimeTypes,
|
||||||
} from '../util';
|
} from '../util';
|
||||||
import {
|
import {
|
||||||
@@ -32,9 +32,7 @@ import Results from './Results';
|
|||||||
import WorkerBridge from '../worker-bridge';
|
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 Transform from './Transform';
|
|
||||||
import { generateCliInvocation } from '../util/cli';
|
import { generateCliInvocation } from '../util/cli';
|
||||||
import { drawableToImageData } from '../util/canvas';
|
|
||||||
|
|
||||||
export type OutputType = EncoderType | 'identity';
|
export type OutputType = EncoderType | 'identity';
|
||||||
|
|
||||||
@@ -71,11 +69,7 @@ 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;
|
|
||||||
mobileView: boolean;
|
mobileView: boolean;
|
||||||
altBackground: boolean;
|
|
||||||
preprocessorState: PreprocessorState;
|
preprocessorState: PreprocessorState;
|
||||||
encodedPreprocessorState?: PreprocessorState;
|
encodedPreprocessorState?: PreprocessorState;
|
||||||
}
|
}
|
||||||
@@ -136,18 +130,13 @@ async function preprocessImage(
|
|||||||
): Promise<ImageData> {
|
): Promise<ImageData> {
|
||||||
assertSignal(signal);
|
assertSignal(signal);
|
||||||
let processedData = data;
|
let processedData = data;
|
||||||
const { rotate, flip, crop } = preprocessorState;
|
|
||||||
|
|
||||||
if (flip.horizontal || flip.vertical) {
|
if (preprocessorState.rotate.rotate !== 0) {
|
||||||
processedData = await workerBridge.flip(signal, processedData, flip);
|
processedData = await workerBridge.rotate(
|
||||||
}
|
signal,
|
||||||
|
processedData,
|
||||||
if (rotate.rotate !== 0) {
|
preprocessorState.rotate,
|
||||||
processedData = await workerBridge.rotate(signal, processedData, rotate);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (crop.left || crop.top || crop.right || crop.bottom) {
|
|
||||||
processedData = await workerBridge.crop(signal, processedData, crop);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return processedData;
|
return processedData;
|
||||||
@@ -292,7 +281,6 @@ export default class Compress extends Component<Props, State> {
|
|||||||
state: State = {
|
state: State = {
|
||||||
source: undefined,
|
source: undefined,
|
||||||
loading: false,
|
loading: false,
|
||||||
transform: false,
|
|
||||||
preprocessorState: defaultPreprocessorState,
|
preprocessorState: defaultPreprocessorState,
|
||||||
sides: [
|
sides: [
|
||||||
{
|
{
|
||||||
@@ -314,7 +302,6 @@ export default class Compress extends Component<Props, State> {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
mobileView: this.widthQuery.matches,
|
mobileView: this.widthQuery.matches,
|
||||||
altBackground: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly encodeCache = new ResultCache();
|
private readonly encodeCache = new ResultCache();
|
||||||
@@ -340,12 +327,6 @@ export default class Compress extends Component<Props, State> {
|
|||||||
this.setState({ mobileView: this.widthQuery.matches });
|
this.setState({ mobileView: this.widthQuery.matches });
|
||||||
};
|
};
|
||||||
|
|
||||||
private toggleBackground = () => {
|
|
||||||
this.setState({
|
|
||||||
altBackground: !this.state.altBackground,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private onEncoderTypeChange = (index: 0 | 1, newType: OutputType): void => {
|
private onEncoderTypeChange = (index: 0 | 1, newType: OutputType): void => {
|
||||||
this.setState({
|
this.setState({
|
||||||
sides: cleanSet(
|
sides: cleanSet(
|
||||||
@@ -387,19 +368,6 @@ export default class Compress extends Component<Props, State> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private showPreprocessorTransforms = () => {
|
|
||||||
this.setState({ transform: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
private onTransformCommit = ({
|
|
||||||
preprocessorState,
|
|
||||||
}: { preprocessorState?: 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;
|
||||||
@@ -471,38 +439,25 @@ export default class Compress extends Component<Props, State> {
|
|||||||
const newRotate = preprocessorState.rotate.rotate;
|
const newRotate = preprocessorState.rotate.rotate;
|
||||||
const orientationChanged = oldRotate % 180 !== newRotate % 180;
|
const orientationChanged = oldRotate % 180 !== newRotate % 180;
|
||||||
|
|
||||||
const { crop } = preprocessorState;
|
|
||||||
const cropChanged = !shallowEqual(crop, this.state.preprocessorState.crop);
|
|
||||||
|
|
||||||
this.setState((state) => ({
|
this.setState((state) => ({
|
||||||
loading: true,
|
loading: true,
|
||||||
preprocessorState,
|
preprocessorState,
|
||||||
// Flip resize values if orientation has changed
|
// Flip resize values if orientation has changed
|
||||||
sides:
|
sides: !orientationChanged
|
||||||
!orientationChanged && !cropChanged
|
? state.sides
|
||||||
? state.sides
|
: (state.sides.map((side) => {
|
||||||
: (state.sides.map((side) => {
|
const currentResizeSettings =
|
||||||
const currentResizeSettings =
|
side.latestSettings.processorState.resize;
|
||||||
side.latestSettings.processorState.resize;
|
const resizeSettings: Partial<ProcessorState['resize']> = {
|
||||||
let resizeSettings: Partial<ProcessorState['resize']>;
|
width: currentResizeSettings.height,
|
||||||
if (cropChanged) {
|
height: currentResizeSettings.width,
|
||||||
const img = state.source?.decoded;
|
};
|
||||||
resizeSettings = {
|
return cleanMerge(
|
||||||
width: img ? img.width - crop.left - crop.right : undefined,
|
side,
|
||||||
height: img ? img.height - crop.top - crop.bottom : undefined,
|
'latestSettings.processorState.resize',
|
||||||
};
|
resizeSettings,
|
||||||
} else {
|
);
|
||||||
resizeSettings = {
|
}) as [Side, Side]),
|
||||||
width: currentResizeSettings.height,
|
|
||||||
height: currentResizeSettings.width,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return cleanMerge(
|
|
||||||
side,
|
|
||||||
'latestSettings.processorState.resize',
|
|
||||||
resizeSettings,
|
|
||||||
);
|
|
||||||
}) as [Side, Side]),
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -880,22 +835,12 @@ export default class Compress extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
{ onBack, showSnack }: Props,
|
{ onBack }: Props,
|
||||||
{
|
{ loading, sides, source, mobileView, 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}
|
||||||
@@ -940,13 +885,8 @@ export default class Compress extends Component<Props, State> {
|
|||||||
rightDisplaySettings.processorState.resize.fitMethod === 'contain';
|
rightDisplaySettings.processorState.resize.fitMethod === 'contain';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div class={style.compress}>
|
||||||
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}
|
||||||
@@ -955,8 +895,6 @@ 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}
|
|
||||||
/>
|
/>
|
||||||
<button class={style.back} onClick={onBack}>
|
<button class={style.back} onClick={onBack}>
|
||||||
<svg viewBox="0 0 61 53.3">
|
<svg viewBox="0 0 61 53.3">
|
||||||
@@ -992,16 +930,6 @@ export default class Compress extends Component<Props, State> {
|
|||||||
</div>,
|
</div>,
|
||||||
]
|
]
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{transform && (
|
|
||||||
<Transform
|
|
||||||
mobileView={mobileView}
|
|
||||||
source={source!}
|
|
||||||
preprocessorState={preprocessorState!}
|
|
||||||
onSave={this.onTransformCommit}
|
|
||||||
onCancel={this.onTransformCommit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,47 +17,6 @@
|
|||||||
'header header header'
|
'header header header'
|
||||||
'optsLeft viewportOpts optsRight';
|
'optsLeft viewportOpts optsRight';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* darker squares background */
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: #000;
|
|
||||||
opacity: 0.8;
|
|
||||||
transition: opacity 500ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.alt-background::before {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* transformation is modal and we sweep away the comparison UI */
|
|
||||||
&.transforming {
|
|
||||||
& > .options {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
& > .options + .options {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 599px) {
|
|
||||||
& > .options {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .back {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > :first-child {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.options {
|
.options {
|
||||||
@@ -74,7 +33,6 @@
|
|||||||
grid-template-rows: 1fr max-content;
|
grid-template-rows: 1fr max-content;
|
||||||
align-content: end;
|
align-content: end;
|
||||||
align-self: end;
|
align-self: end;
|
||||||
transition: transform 500ms ease;
|
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
@media (min-width: 600px) {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
|
|||||||
@@ -1,124 +1,61 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
|
|
||||||
interface IconProps extends preact.JSX.SVGAttributes {
|
const Icon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
transform?: string;
|
// @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019
|
||||||
}
|
<svg
|
||||||
|
width="24"
|
||||||
const Icon = ({ transform, children, ...props }: IconProps) => {
|
height="24"
|
||||||
if (transform) {
|
viewBox="0 0 24 24"
|
||||||
children = (
|
fill="currentColor"
|
||||||
<g style={{ transformOrigin: 'center', transform }}>{children}</g>
|
{...props}
|
||||||
);
|
/>
|
||||||
}
|
|
||||||
return (
|
|
||||||
// @ts-ignore - TS bug https://github.com/microsoft/TypeScript/issues/16019
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CLIIcon = (props: IconProps) => (
|
|
||||||
<Icon {...props}>
|
|
||||||
<path d="M1 2.7H23v18.5H1zm5.5 13l3.7-3.7-3.7-3.7m5.5 7.4h5.6" />
|
|
||||||
</Icon>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SwapIcon = (props: IconProps) => (
|
export const ToggleBackgroundIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
<Icon {...props}>
|
|
||||||
<path d="M8.5 8.6v6.8L5.1 12l3.4-3.4M10 5l-7 7 7 7V5zm4 0v14l7-7-7-7z" />
|
|
||||||
</Icon>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const FlipVerticallyIcon = (props: IconProps) => (
|
|
||||||
<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: IconProps) => (
|
|
||||||
<FlipVerticallyIcon {...props} transform="rotate(90deg)" />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const RotateClockwiseIcon = (props: IconProps) => (
|
|
||||||
<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: IconProps) => (
|
|
||||||
<RotateClockwiseIcon {...props} transform="scaleX(-1)" />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CheckmarkIcon = (props: IconProps) => (
|
|
||||||
<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: IconProps) => (
|
|
||||||
<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 ToggleBackgroundIcon = (props: IconProps) => (
|
|
||||||
<Icon {...props}>
|
<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" />
|
<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>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ToggleBackgroundActiveIcon = (props: IconProps) => (
|
export const ToggleBackgroundActiveIcon = (
|
||||||
|
props: preact.JSX.HTMLAttributes,
|
||||||
|
) => (
|
||||||
<Icon {...props}>
|
<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" />
|
<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>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const RotateIcon = (props: IconProps) => (
|
export const RotateIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
<Icon {...props}>
|
<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" />
|
<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>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const MoreIcon = (props: IconProps) => (
|
export const AddIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
<Icon {...props}>
|
|
||||||
<circle cx="12" cy="6" r="2" />
|
|
||||||
<circle cx="12" cy="12" r="2" />
|
|
||||||
<circle cx="12" cy="18" r="2" />
|
|
||||||
</Icon>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const AddIcon = (props: IconProps) => (
|
|
||||||
<Icon {...props}>
|
<Icon {...props}>
|
||||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||||
</Icon>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const RemoveIcon = (props: IconProps) => (
|
export const RemoveIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
<Icon {...props}>
|
<Icon {...props}>
|
||||||
<path d="M19 13H5v-2h14v2z" />
|
<path d="M19 13H5v-2h14v2z" />
|
||||||
</Icon>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const UncheckedIcon = (props: IconProps) => (
|
export const UncheckedIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
<Icon {...props}>
|
<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" />
|
<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>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const CheckedIcon = (props: IconProps) => (
|
export const CheckedIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
<Icon {...props}>
|
<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" />
|
<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>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ExpandIcon = (props: IconProps) => (
|
export const ExpandIcon = (props: preact.JSX.HTMLAttributes) => (
|
||||||
<Icon {...props}>
|
<Icon {...props}>
|
||||||
<path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z" />
|
<path d="M16.6 8.6L12 13.2 7.4 8.6 6 10l6 6 6-6z" />
|
||||||
</Icon>
|
</Icon>
|
||||||
@@ -135,3 +72,20 @@ export const DownloadIcon = () => (
|
|||||||
<path d="M6.6 2.7h-4v13.2h2.7A2.7 2.7 0 018 18.6a2.7 2.7 0 002.6 2.6h2.7a2.7 2.7 0 002.6-2.6 2.7 2.7 0 012.7-2.7h2.6V2.7h-4a1.3 1.3 0 110-2.7h4A2.7 2.7 0 0124 2.7v18.5a2.7 2.7 0 01-2.7 2.7H2.7A2.7 2.7 0 010 21.2V2.7A2.7 2.7 0 012.7 0h4a1.3 1.3 0 010 2.7zm4 7.4V1.3a1.3 1.3 0 112.7 0v8.8L15 8.4a1.3 1.3 0 011.9 1.8l-4 4a1.3 1.3 0 01-1.9 0l-4-4A1.3 1.3 0 019 8.4z" />
|
<path d="M6.6 2.7h-4v13.2h2.7A2.7 2.7 0 018 18.6a2.7 2.7 0 002.6 2.6h2.7a2.7 2.7 0 002.6-2.6 2.7 2.7 0 012.7-2.7h2.6V2.7h-4a1.3 1.3 0 110-2.7h4A2.7 2.7 0 0124 2.7v18.5a2.7 2.7 0 01-2.7 2.7H2.7A2.7 2.7 0 010 21.2V2.7A2.7 2.7 0 012.7 0h4a1.3 1.3 0 010 2.7zm4 7.4V1.3a1.3 1.3 0 112.7 0v8.8L15 8.4a1.3 1.3 0 011.9 1.8l-4 4a1.3 1.3 0 01-1.9 0l-4-4A1.3 1.3 0 019 8.4z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CLIIcon = () => (
|
||||||
|
<svg viewBox="0 0 81.3 68.8">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke-miterlimit="15.6"
|
||||||
|
stroke-width="6.3"
|
||||||
|
d="M3.1 3.1h75v62.5h-75zm18.8 43.8l12.5-12.5-12.5-12.5m18.7 25h18.8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SwapIcon = () => (
|
||||||
|
<svg viewBox="0 0 18 14">
|
||||||
|
<path d="M5.5 3.6v6.8L2.1 7l3.4-3.4M7 0L0 7l7 7V0zm4 0v14l7-7-7-7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
/** Replace the contents of a canvas with the given data */
|
|
||||||
export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) {
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) throw Error('Canvas not initialized');
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.putImageData(data, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode some image data in a given format using the browser's encoders
|
|
||||||
*
|
|
||||||
* @param {ImageData} data
|
|
||||||
* @param {string} type A mime type, eg image/jpeg.
|
|
||||||
* @param {number} [quality] Between 0-1.
|
|
||||||
*/
|
|
||||||
export async function canvasEncode(
|
|
||||||
data: ImageData,
|
|
||||||
type: string,
|
|
||||||
quality?: number,
|
|
||||||
): Promise<Blob> {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = data.width;
|
|
||||||
canvas.height = data.height;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) throw Error('Canvas not initialized');
|
|
||||||
ctx.putImageData(data, 0, 0);
|
|
||||||
|
|
||||||
let blob: Blob | null;
|
|
||||||
|
|
||||||
if ('toBlob' in canvas) {
|
|
||||||
blob = await new Promise<Blob | null>((r) =>
|
|
||||||
canvas.toBlob(r, type, quality),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Welcome to Edge.
|
|
||||||
// TypeScript thinks `canvas` is 'never', so it needs casting.
|
|
||||||
const dataUrl = (canvas as HTMLCanvasElement).toDataURL(type, quality);
|
|
||||||
const result = /data:([^;]+);base64,(.*)$/.exec(dataUrl);
|
|
||||||
|
|
||||||
if (!result) throw Error('Data URL reading failed');
|
|
||||||
|
|
||||||
const outputType = result[1];
|
|
||||||
const binaryStr = atob(result[2]);
|
|
||||||
const data = new Uint8Array(binaryStr.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i += 1) {
|
|
||||||
data[i] = binaryStr.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
blob = new Blob([data], { type: outputType });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!blob) throw Error('Encoding failed');
|
|
||||||
return blob;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DrawableToImageDataOptions {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
sx?: number;
|
|
||||||
sy?: number;
|
|
||||||
sw?: number;
|
|
||||||
sh?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWidth(
|
|
||||||
drawable: ImageBitmap | HTMLImageElement | VideoFrame,
|
|
||||||
): number {
|
|
||||||
if ('displayWidth' in drawable) {
|
|
||||||
return drawable.displayWidth;
|
|
||||||
}
|
|
||||||
return drawable.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHeight(
|
|
||||||
drawable: ImageBitmap | HTMLImageElement | VideoFrame,
|
|
||||||
): number {
|
|
||||||
if ('displayHeight' in drawable) {
|
|
||||||
return drawable.displayHeight;
|
|
||||||
}
|
|
||||||
return drawable.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function drawableToImageData(
|
|
||||||
drawable: ImageBitmap | HTMLImageElement | VideoFrame,
|
|
||||||
opts: DrawableToImageDataOptions = {},
|
|
||||||
): ImageData {
|
|
||||||
const {
|
|
||||||
width = getWidth(drawable),
|
|
||||||
height = getHeight(drawable),
|
|
||||||
sx = 0,
|
|
||||||
sy = 0,
|
|
||||||
sw = getWidth(drawable),
|
|
||||||
sh = getHeight(drawable),
|
|
||||||
} = opts;
|
|
||||||
|
|
||||||
// Make canvas same size as image
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
// Draw image onto canvas
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) throw new Error('Could not create canvas context');
|
|
||||||
ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
|
|
||||||
return ctx.getImageData(0, 0, width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BuiltinResizeMethod = 'pixelated' | 'low' | 'medium' | 'high';
|
|
||||||
|
|
||||||
export function builtinResize(
|
|
||||||
data: ImageData,
|
|
||||||
sx: number,
|
|
||||||
sy: number,
|
|
||||||
sw: number,
|
|
||||||
sh: number,
|
|
||||||
dw: number,
|
|
||||||
dh: number,
|
|
||||||
method: BuiltinResizeMethod,
|
|
||||||
): ImageData {
|
|
||||||
const canvasSource = document.createElement('canvas');
|
|
||||||
canvasSource.width = data.width;
|
|
||||||
canvasSource.height = data.height;
|
|
||||||
drawDataToCanvas(canvasSource, data);
|
|
||||||
|
|
||||||
const canvasDest = document.createElement('canvas');
|
|
||||||
canvasDest.width = dw;
|
|
||||||
canvasDest.height = dh;
|
|
||||||
const ctx = canvasDest.getContext('2d');
|
|
||||||
if (!ctx) throw new Error('Could not create canvas context');
|
|
||||||
|
|
||||||
if (method === 'pixelated') {
|
|
||||||
ctx.imageSmoothingEnabled = false;
|
|
||||||
} else {
|
|
||||||
ctx.imageSmoothingQuality = method;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.drawImage(canvasSource, sx, sy, sw, sh, 0, 0, dw, dh);
|
|
||||||
return ctx.getImageData(0, 0, dw, dh);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test whether <canvas> can encode to a particular type.
|
|
||||||
*/
|
|
||||||
export async function canvasEncodeTest(mimeType: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const blob = await canvasEncode(new ImageData(1, 1), mimeType);
|
|
||||||
// According to the spec, the blob should be null if the format isn't supported…
|
|
||||||
if (!blob) return false;
|
|
||||||
// …but Safari & Firefox fall back to PNG, so we need to check the mime type.
|
|
||||||
return blob.type === mimeType;
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as WebCodecs from '../util/web-codecs';
|
import * as WebCodecs from '../util/web-codecs';
|
||||||
import { drawableToImageData } from './canvas';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare two objects, returning a boolean indicating if
|
* Compare two objects, returning a boolean indicating if
|
||||||
@@ -24,6 +23,62 @@ export function shallowEqual(one: any, two: any) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace the contents of a canvas with the given data */
|
||||||
|
export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw Error('Canvas not initialized');
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.putImageData(data, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode some image data in a given format using the browser's encoders
|
||||||
|
*
|
||||||
|
* @param {ImageData} data
|
||||||
|
* @param {string} type A mime type, eg image/jpeg.
|
||||||
|
* @param {number} [quality] Between 0-1.
|
||||||
|
*/
|
||||||
|
export async function canvasEncode(
|
||||||
|
data: ImageData,
|
||||||
|
type: string,
|
||||||
|
quality?: number,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = data.width;
|
||||||
|
canvas.height = data.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw Error('Canvas not initialized');
|
||||||
|
ctx.putImageData(data, 0, 0);
|
||||||
|
|
||||||
|
let blob: Blob | null;
|
||||||
|
|
||||||
|
if ('toBlob' in canvas) {
|
||||||
|
blob = await new Promise<Blob | null>((r) =>
|
||||||
|
canvas.toBlob(r, type, quality),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Welcome to Edge.
|
||||||
|
// TypeScript thinks `canvas` is 'never', so it needs casting.
|
||||||
|
const dataUrl = (canvas as HTMLCanvasElement).toDataURL(type, quality);
|
||||||
|
const result = /data:([^;]+);base64,(.*)$/.exec(dataUrl);
|
||||||
|
|
||||||
|
if (!result) throw Error('Data URL reading failed');
|
||||||
|
|
||||||
|
const outputType = result[1];
|
||||||
|
const binaryStr = atob(result[2]);
|
||||||
|
const data = new Uint8Array(binaryStr.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += 1) {
|
||||||
|
data[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
blob = new Blob([data], { type: outputType });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blob) throw Error('Encoding failed');
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
async function decodeImage(url: string): Promise<HTMLImageElement> {
|
async function decodeImage(url: string): Promise<HTMLImageElement> {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.decoding = 'async';
|
img.decoding = 'async';
|
||||||
@@ -131,6 +186,57 @@ export async function blobToImg(blob: Blob): Promise<HTMLImageElement> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DrawableToImageDataOptions {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
sx?: number;
|
||||||
|
sy?: number;
|
||||||
|
sw?: number;
|
||||||
|
sh?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWidth(
|
||||||
|
drawable: ImageBitmap | HTMLImageElement | VideoFrame,
|
||||||
|
): number {
|
||||||
|
if ('displayWidth' in drawable) {
|
||||||
|
return drawable.displayWidth;
|
||||||
|
}
|
||||||
|
return drawable.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeight(
|
||||||
|
drawable: ImageBitmap | HTMLImageElement | VideoFrame,
|
||||||
|
): number {
|
||||||
|
if ('displayHeight' in drawable) {
|
||||||
|
return drawable.displayHeight;
|
||||||
|
}
|
||||||
|
return drawable.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawableToImageData(
|
||||||
|
drawable: ImageBitmap | HTMLImageElement | VideoFrame,
|
||||||
|
opts: DrawableToImageDataOptions = {},
|
||||||
|
): ImageData {
|
||||||
|
const {
|
||||||
|
width = getWidth(drawable),
|
||||||
|
height = getHeight(drawable),
|
||||||
|
sx = 0,
|
||||||
|
sy = 0,
|
||||||
|
sw = getWidth(drawable),
|
||||||
|
sh = getHeight(drawable),
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// Make canvas same size as image
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
// Draw image onto canvas
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Could not create canvas context');
|
||||||
|
ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height);
|
||||||
|
return ctx.getImageData(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
export async function builtinDecode(
|
export async function builtinDecode(
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
@@ -153,6 +259,39 @@ export async function builtinDecode(
|
|||||||
return drawableToImageData(drawable);
|
return drawableToImageData(drawable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BuiltinResizeMethod = 'pixelated' | 'low' | 'medium' | 'high';
|
||||||
|
|
||||||
|
export function builtinResize(
|
||||||
|
data: ImageData,
|
||||||
|
sx: number,
|
||||||
|
sy: number,
|
||||||
|
sw: number,
|
||||||
|
sh: number,
|
||||||
|
dw: number,
|
||||||
|
dh: number,
|
||||||
|
method: BuiltinResizeMethod,
|
||||||
|
): ImageData {
|
||||||
|
const canvasSource = document.createElement('canvas');
|
||||||
|
canvasSource.width = data.width;
|
||||||
|
canvasSource.height = data.height;
|
||||||
|
drawDataToCanvas(canvasSource, data);
|
||||||
|
|
||||||
|
const canvasDest = document.createElement('canvas');
|
||||||
|
canvasDest.width = dw;
|
||||||
|
canvasDest.height = dh;
|
||||||
|
const ctx = canvasDest.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Could not create canvas context');
|
||||||
|
|
||||||
|
if (method === 'pixelated') {
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
} else {
|
||||||
|
ctx.imageSmoothingQuality = method;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(canvasSource, sx, sy, sw, sh, 0, 0, dw, dh);
|
||||||
|
return ctx.getImageData(0, 0, dw, dh);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
|
* @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
|
||||||
* @param defaultVal Value to return if 'field' doesn't exist.
|
* @param defaultVal Value to return if 'field' doesn't exist.
|
||||||
@@ -295,3 +434,18 @@ export async function abortable<T>(
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether <canvas> can encode to a particular type.
|
||||||
|
*/
|
||||||
|
export async function canvasEncodeTest(mimeType: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const blob = await canvasEncode(new ImageData(1, 1), mimeType);
|
||||||
|
// According to the spec, the blob should be null if the format isn't supported…
|
||||||
|
if (!blob) return false;
|
||||||
|
// …but Safari & Firefox fall back to PNG, so we need to check the mime type.
|
||||||
|
return blob.type === mimeType;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { drawableToImageData } from '../canvas';
|
import { drawableToImageData } from 'client/lazy-app/util';
|
||||||
|
|
||||||
const hasImageDecoder = typeof ImageDecoder !== 'undefined';
|
const hasImageDecoder = typeof ImageDecoder !== 'undefined';
|
||||||
|
|
||||||
export async function isTypeSupported(mimeType: string): Promise<boolean> {
|
export async function isTypeSupported(mimeType: string): Promise<boolean> {
|
||||||
if (!hasImageDecoder) return false;
|
if (!hasImageDecoder) {
|
||||||
// Some old versions of this API threw here.
|
|
||||||
// It only impacted folks with experimental web platform flags enabled in Chrome 90.
|
|
||||||
// The API was updated in Chrome 91.
|
|
||||||
try {
|
|
||||||
return await ImageDecoder.isTypeSupported(mimeType);
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return ImageDecoder.isTypeSupported(mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decode(
|
export async function decode(
|
||||||
blob: Blob | File,
|
blob: Blob | File,
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { canvasEncodeTest, canvasEncode } from 'client/lazy-app/util/canvas';
|
import { canvasEncodeTest, canvasEncode } from 'client/lazy-app/util';
|
||||||
import WorkerBridge from 'client/lazy-app/worker-bridge';
|
import WorkerBridge from 'client/lazy-app/worker-bridge';
|
||||||
import { EncodeOptions, mimeType } from '../shared/meta';
|
import { EncodeOptions, mimeType } from '../shared/meta';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { canvasEncode } from 'client/lazy-app/util/canvas';
|
import { canvasEncode } from 'client/lazy-app/util';
|
||||||
import WorkerBridge from 'client/lazy-app/worker-bridge';
|
import WorkerBridge from 'client/lazy-app/worker-bridge';
|
||||||
import { qualityOption } from 'features/client-utils';
|
import { qualityOption } from 'features/client-utils';
|
||||||
import { mimeType, EncodeOptions } from '../shared/meta';
|
import { mimeType, EncodeOptions } from '../shared/meta';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { canvasEncode } from 'client/lazy-app/util/canvas';
|
import { canvasEncode } from 'client/lazy-app/util';
|
||||||
import WorkerBridge from 'client/lazy-app/worker-bridge';
|
import WorkerBridge from 'client/lazy-app/worker-bridge';
|
||||||
import { EncodeOptions, mimeType } from '../shared/meta';
|
import { EncodeOptions, mimeType } from '../shared/meta';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { canvasEncode } from 'client/lazy-app/util/canvas';
|
|
||||||
import {
|
import {
|
||||||
|
canvasEncode,
|
||||||
abortable,
|
abortable,
|
||||||
blobToArrayBuffer,
|
blobToArrayBuffer,
|
||||||
inputFieldChecked,
|
inputFieldChecked,
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright 2020 Google Inc. All Rights Reserved.
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
import { Options } from '../shared/meta';
|
|
||||||
|
|
||||||
export default async function crop(
|
|
||||||
{ data, width, height }: ImageData,
|
|
||||||
{ top, right, bottom, left }: Options,
|
|
||||||
): Promise<ImageData> {
|
|
||||||
const newWidth = width - left - right;
|
|
||||||
const newHeight = height - top - bottom;
|
|
||||||
|
|
||||||
const cols = width * 4;
|
|
||||||
const newCols = newWidth * 4;
|
|
||||||
|
|
||||||
const pixels = new Uint8ClampedArray(newHeight * newCols);
|
|
||||||
for (let y = 0; y < newHeight; y++) {
|
|
||||||
const x = left * 4;
|
|
||||||
const row = new Uint8ClampedArray(
|
|
||||||
data.buffer,
|
|
||||||
(top + y) * cols + x,
|
|
||||||
newCols,
|
|
||||||
);
|
|
||||||
pixels.set(row, y * newCols);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ImageData(pixels, newWidth, newHeight);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright 2020 Google Inc. All Rights Reserved.
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
import { Options } from '../shared/meta';
|
|
||||||
|
|
||||||
export default async function flip(
|
|
||||||
data: ImageData,
|
|
||||||
opts: Options,
|
|
||||||
): Promise<ImageData> {
|
|
||||||
const { vertical, horizontal } = opts;
|
|
||||||
const source = data.data;
|
|
||||||
const len = source.length;
|
|
||||||
const pixels = new Uint8ClampedArray(len);
|
|
||||||
const { width, height } = data;
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
let x = 0;
|
|
||||||
let y = 0;
|
|
||||||
const cols = width * 4;
|
|
||||||
while (i < len) {
|
|
||||||
let from = vertical ? (height - y) * cols + x * 4 : i;
|
|
||||||
if (horizontal) from = from - x * 4 + cols - x * 4;
|
|
||||||
|
|
||||||
pixels[i++] = source[from++];
|
|
||||||
pixels[i++] = source[from++];
|
|
||||||
pixels[i++] = source[from++];
|
|
||||||
pixels[i++] = source[from];
|
|
||||||
|
|
||||||
if (++x === width) {
|
|
||||||
x = 0;
|
|
||||||
y++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ImageData(pixels, data.width, data.height);
|
|
||||||
}
|
|
||||||
@@ -10,12 +10,4 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
export interface Options {
|
/// <reference path="../../../../../missing-types.d.ts" />
|
||||||
horizontal: boolean;
|
|
||||||
vertical: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultOptions: Options = {
|
|
||||||
horizontal: false,
|
|
||||||
vertical: false,
|
|
||||||
};
|
|
||||||
@@ -10,16 +10,4 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
export interface Options {
|
/// <reference path="../../../../../missing-types.d.ts" />
|
||||||
left: number;
|
|
||||||
right: number;
|
|
||||||
top: number;
|
|
||||||
bottom: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultOptions: Options = {
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
builtinResize,
|
builtinResize,
|
||||||
BuiltinResizeMethod,
|
BuiltinResizeMethod,
|
||||||
drawableToImageData,
|
drawableToImageData,
|
||||||
} from 'client/lazy-app/util/canvas';
|
} from 'client/lazy-app/util';
|
||||||
import {
|
import {
|
||||||
BrowserResizeOptions,
|
BrowserResizeOptions,
|
||||||
VectorResizeOptions,
|
VectorResizeOptions,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import artwork from 'url:./imgs/demos/demo-artwork.jpg';
|
|||||||
import deviceScreen from 'url:./imgs/demos/demo-device-screen.png';
|
import deviceScreen from 'url:./imgs/demos/demo-device-screen.png';
|
||||||
import largePhotoIcon from 'url:./imgs/demos/icon-demo-large-photo.jpg';
|
import largePhotoIcon from 'url:./imgs/demos/icon-demo-large-photo.jpg';
|
||||||
import artworkIcon from 'url:./imgs/demos/icon-demo-artwork.jpg';
|
import artworkIcon from 'url:./imgs/demos/icon-demo-artwork.jpg';
|
||||||
import deviceScreenIcon from 'url:./imgs/demos/icon-demo-device-screen.jpg';
|
//import deviceScreenIcon from 'url:./imgs/demos/icon-demo-device-screen.jpg';
|
||||||
import logoIcon from 'url:./imgs/demos/icon-demo-logo.png';
|
import logoIcon from 'url:./imgs/demos/icon-demo-logo.png';
|
||||||
import logoWithText from 'url:./imgs/logo-with-text.svg';
|
import logoWithText from 'url:./imgs/logo-with-text.svg';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
@@ -17,6 +17,12 @@ import type SnackBarElement from 'shared/custom-els/snack-bar';
|
|||||||
import 'shared/custom-els/snack-bar';
|
import 'shared/custom-els/snack-bar';
|
||||||
import { startBlobs } from './blob-anim/meta';
|
import { startBlobs } from './blob-anim/meta';
|
||||||
|
|
||||||
|
const deviceScreenIcon = new URL(
|
||||||
|
'./imgs/demos/icon-demo-device-screen.jpg',
|
||||||
|
// @ts-ignore
|
||||||
|
import.meta.url,
|
||||||
|
).href;
|
||||||
|
|
||||||
const demos = [
|
const demos = [
|
||||||
{
|
{
|
||||||
description: 'Large photo',
|
description: 'Large photo',
|
||||||
@@ -273,7 +279,7 @@ export default class Intro extends Component<Props, State> {
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
class={style.loadImgContent}
|
class={style.loadImgContent}
|
||||||
style={{ visibility: __PRERENDER__ ? 'hidden' : undefined }}
|
style={{ visibility: __PRERENDER__ ? 'hidden' : '' }}
|
||||||
>
|
>
|
||||||
<button class={style.loadBtn} onClick={this.onOpenClick}>
|
<button class={style.loadBtn} onClick={this.onOpenClick}>
|
||||||
<svg viewBox="0 0 24 24" class={style.loadIcon}>
|
<svg viewBox="0 0 24 24" class={style.loadIcon}>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ html {
|
|||||||
--less-light-gray: #bcbcbc;
|
--less-light-gray: #bcbcbc;
|
||||||
--medium-light-gray: #d1d1d1;
|
--medium-light-gray: #d1d1d1;
|
||||||
--light-gray: #eaeaea;
|
--light-gray: #eaeaea;
|
||||||
--med-gray: #555;
|
|
||||||
--dark-gray: #333;
|
--dark-gray: #333;
|
||||||
--dim-text: #343a3e;
|
--dim-text: #343a3e;
|
||||||
--dark-text: #142630;
|
--dark-text: #142630;
|
||||||
|
|||||||
Reference in New Issue
Block a user