creates a zip
This commit is contained in:
parent
d62d3a5613
commit
6b852d0fa3
|
@ -20,6 +20,7 @@
|
|||
"@types/react": "^18.0.38",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"ahooks": "^3.7.6",
|
||||
"client-zip": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-image-crop": "^10.0.9",
|
||||
|
@ -1838,6 +1839,11 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/client-zip": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/client-zip/-/client-zip-2.3.1.tgz",
|
||||
"integrity": "sha512-iRSvLjnKWXln/Q3m4thNe7IQWHBZg1fYjnuFXjLOgXg7GQYTFdVfHJ3n1J7qpgFpGbfXYt8pfngfIl/FVDYPqg=="
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
"@types/react": "^18.0.38",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"ahooks": "^3.7.6",
|
||||
"client-zip": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-image-crop": "^10.0.9",
|
||||
|
@ -23,6 +24,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"startAllAddrs": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
|
|
80
src/App.tsx
80
src/App.tsx
|
@ -15,9 +15,13 @@ import ReactCrop, {
|
|||
import { useDebounceEffect } from 'ahooks';
|
||||
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
import { AppShell, Button, Container, FileButton, Grid, Header, NativeSelect, Navbar, NavLink, NumberInput, TextInput } from '@mantine/core';
|
||||
import { AppShell, Aside, Button, Container, FileButton, Grid, Header, NativeSelect, Navbar, NavLink, NumberInput, Paper, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { cropImageToTargetDimensions } from './imageCropper';
|
||||
import CropCanvas from './CropCanvas';
|
||||
import CropCanvas, { OutputImage } from './CropCanvas';
|
||||
import WallHeightCropControl from './WallHeightCropControl';
|
||||
import { AccumulatingAwaitableEvent } from './CustomAwaitableEvent';
|
||||
import { ExportCropsEvent } from './Events';
|
||||
import { downloadZipWithFiles } from './MakeZip';
|
||||
|
||||
const wallChoices = ["small", "medium", "tall"];
|
||||
|
||||
|
@ -77,6 +81,7 @@ export default function App() {
|
|||
const [numTiles, setNumTiles] = useState<number | ''>(3)
|
||||
const [dimensions, setDimensions] = useState<WallDimensions>({height: wallSizes.small, totalWidth: wallTileWidth, tileCount: 1, diffuseUvScale: 0})
|
||||
const [inputFile, setInputFile] = useState<File | null>(null)
|
||||
const [debugMode, setDebugMode] = useState<boolean>(false)
|
||||
|
||||
|
||||
function downloadCanvas(canvas: HTMLCanvasElement) {
|
||||
|
@ -100,9 +105,10 @@ export default function App() {
|
|||
downloadCanvas(fullWidthCanvasRef.current);
|
||||
}
|
||||
|
||||
function onDoCropClick() {
|
||||
document.dispatchEvent(new Event("triggerCrop"));
|
||||
|
||||
async function onDoCropClick() {
|
||||
const allImages = await ExportCropsEvent.signalAndWaitForAllProcessors();
|
||||
console.log(`got ${allImages.length} images`);
|
||||
await downloadZipWithFiles("wall.zip", allImages);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
|
@ -169,7 +175,12 @@ export default function App() {
|
|||
<AppShell
|
||||
padding="md"
|
||||
navbar={<Navbar width={{ base: 300 }} height={500} p="xs">
|
||||
{navItems}
|
||||
<Navbar.Section grow>
|
||||
{navItems}
|
||||
</Navbar.Section>
|
||||
<Navbar.Section>
|
||||
<Switch checked={debugMode} onChange={(event) => setDebugMode(event.currentTarget.checked)} label="Show extra debug controls" />
|
||||
</Navbar.Section>
|
||||
</Navbar>}
|
||||
header={<Header height={60} p="xs">{/* Header content */}</Header>}
|
||||
styles={(theme) => ({
|
||||
|
@ -220,20 +231,53 @@ export default function App() {
|
|||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
{!!imgSrc && (
|
||||
<CropCanvas
|
||||
sectionLabel='section'
|
||||
helpLabel='help'
|
||||
<>
|
||||
<WallHeightCropControl
|
||||
sectionLabel='Short walls'
|
||||
helpLabel='The portion of the image to use for short walls'
|
||||
imgSrc={imgSrc}
|
||||
aspect={dimensions.totalWidth / dimensions.height}
|
||||
triggerCropEventName="triggerCrop"
|
||||
onCropCompleted={(n)=> console.log("handlign onCropCompoleted")}
|
||||
outputSpecs={[{
|
||||
width: dimensions.totalWidth,
|
||||
height: dimensions.height,
|
||||
name: "normal"
|
||||
}]}
|
||||
showDebugControls={true}
|
||||
tileWidth={wallTileWidth}
|
||||
tileHeight={wallSizes.small}
|
||||
outputFileLabel="small.png"
|
||||
accumulator={ExportCropsEvent}
|
||||
showDebugControls={debugMode}
|
||||
/>
|
||||
<WallHeightCropControl
|
||||
sectionLabel='Medium walls'
|
||||
helpLabel='The portion of the image to use for medium walls'
|
||||
imgSrc={imgSrc}
|
||||
tileWidth={wallTileWidth}
|
||||
tileHeight={wallSizes.medium}
|
||||
outputFileLabel="medium.png"
|
||||
accumulator={ExportCropsEvent}
|
||||
showDebugControls={debugMode}
|
||||
/>
|
||||
<WallHeightCropControl
|
||||
sectionLabel='Tall walls'
|
||||
helpLabel='The portion of the image to use for tall walls'
|
||||
imgSrc={imgSrc}
|
||||
tileWidth={wallTileWidth}
|
||||
tileHeight={wallSizes.tall}
|
||||
outputFileLabel="tall.png"
|
||||
accumulator={ExportCropsEvent}
|
||||
showDebugControls={debugMode}
|
||||
/>
|
||||
|
||||
<Paper shadow="xs" p="md">
|
||||
<Title order={3}>Catalog thumbnail</Title>
|
||||
<CropCanvas
|
||||
imgSrc={imgSrc}
|
||||
aspect={1}
|
||||
accumulator={ExportCropsEvent}
|
||||
outputSpecs={[{
|
||||
width: 116,
|
||||
height: 116,
|
||||
name: "thumbnail.png"
|
||||
}]}
|
||||
showDebugControls={debugMode}
|
||||
/>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</Grid.Col>
|
||||
</Grid>)}
|
||||
|
|
|
@ -14,7 +14,9 @@ import ReactCrop, {
|
|||
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
import { cropImageToTargetDimensions } from './imageCropper';
|
||||
import { Button, TextInput } from '@mantine/core';
|
||||
import { Button, Paper, TextInput, Title, Text } from '@mantine/core';
|
||||
import { AccumulatingAwaitableEvent } from './CustomAwaitableEvent';
|
||||
import { useDebounceEffect } from 'ahooks';
|
||||
|
||||
|
||||
// This is to demonstate how to make and center a % aspect crop
|
||||
|
@ -39,33 +41,29 @@ function centerAspectCrop(
|
|||
)
|
||||
}
|
||||
|
||||
interface OutputImageSpec {
|
||||
export interface OutputImageSpec {
|
||||
width: number,
|
||||
height: number,
|
||||
name: string,
|
||||
}
|
||||
|
||||
interface OutputImage {
|
||||
export interface OutputImage {
|
||||
name: string,
|
||||
blob: Blob,
|
||||
}
|
||||
|
||||
interface CanvasProps {
|
||||
sectionLabel: string,
|
||||
helpLabel: string,
|
||||
imgSrc: string,
|
||||
aspect: number,
|
||||
triggerCropEventName: string,
|
||||
onCropCompleted: (n: OutputImage[]) => void,
|
||||
accumulator: AccumulatingAwaitableEvent<OutputImage>,
|
||||
outputSpecs: OutputImageSpec[],
|
||||
showDebugControls: boolean,
|
||||
}
|
||||
|
||||
export default function CropCanvas({imgSrc, aspect, triggerCropEventName, onCropCompleted, outputSpecs, showDebugControls}: CanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
export default function CropCanvas({imgSrc, aspect, accumulator, outputSpecs, showDebugControls}: CanvasProps) {
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const [crop, setCrop] = useState<Crop>()
|
||||
const [locked, setLocked] = useState<boolean>()
|
||||
const [locked, setLocked] = useState<boolean>(false)
|
||||
|
||||
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
if (aspect) {
|
||||
|
@ -74,9 +72,12 @@ export default function CropCanvas({imgSrc, aspect, triggerCropEventName, onCrop
|
|||
}
|
||||
}
|
||||
|
||||
async function handleTriggerCrop() {
|
||||
console.log("cropping");
|
||||
|
||||
async function handleTriggerCrop(): Promise<OutputImage[]> {
|
||||
const result = [];
|
||||
for (const spec of outputSpecs){
|
||||
result.push(await cropToSpec(spec));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleClickDebugCropSingleSpec(spec: OutputImageSpec) {
|
||||
|
@ -88,57 +89,40 @@ export default function CropCanvas({imgSrc, aspect, triggerCropEventName, onCrop
|
|||
}
|
||||
|
||||
async function cropToSpec(spec: OutputImageSpec): Promise<OutputImage> {
|
||||
if (locked) {
|
||||
throw new Error("control is already locked.");
|
||||
if (!imgRef.current){
|
||||
throw new Error("imageref is null");
|
||||
}
|
||||
setLocked(true);
|
||||
try {
|
||||
if (!crop){
|
||||
throw new Error("crop is null");
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
cropImageToTargetDimensions(imgRef.current, canvas, crop, spec.width, spec.height);
|
||||
|
||||
if (!imgRef.current){
|
||||
throw new Error("imageref is null");
|
||||
}
|
||||
if (!canvasRef.current){
|
||||
throw new Error("canvasref is null");
|
||||
}
|
||||
if (!crop){
|
||||
throw new Error("crop is null");
|
||||
}
|
||||
cropImageToTargetDimensions(imgRef.current, canvasRef.current, crop, spec.width, spec.height);
|
||||
|
||||
const blob: Blob = await new Promise((resolve, error) => {
|
||||
if (!canvasRef.current){
|
||||
error(new Error("canvasref is null inside of retrieving the blob."));
|
||||
const blob: Blob = await new Promise((resolve, error) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob){
|
||||
error(new Error("Failed to create blob"));
|
||||
}
|
||||
else {
|
||||
resolve(blob);
|
||||
}
|
||||
canvasRef.current?.toBlob((blob) => {
|
||||
if (!blob){
|
||||
error(new Error("Failed to create blob"));
|
||||
}
|
||||
else {
|
||||
resolve(blob);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
name: spec.name,
|
||||
blob: blob
|
||||
}
|
||||
|
||||
return {
|
||||
name: spec.name,
|
||||
blob: blob
|
||||
}
|
||||
finally {
|
||||
setLocked(false);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = () => handleTriggerCrop();
|
||||
document.addEventListener(triggerCropEventName, handler);
|
||||
useDebounceEffect(() => {
|
||||
accumulator.addEventProcessor(handleTriggerCrop)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener(triggerCropEventName, handler);
|
||||
accumulator.removeEventProcessor(handleTriggerCrop)
|
||||
}
|
||||
}, [triggerCropEventName])
|
||||
}, [accumulator, crop], {wait: 100});
|
||||
|
||||
React.useEffect(() => {
|
||||
// Reset crop if the requested aspect changes
|
||||
|
@ -153,7 +137,7 @@ export default function CropCanvas({imgSrc, aspect, triggerCropEventName, onCrop
|
|||
}, [aspect])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper shadow="xs" p="md">
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
||||
|
@ -169,12 +153,6 @@ export default function CropCanvas({imgSrc, aspect, triggerCropEventName, onCrop
|
|||
<div style={{
|
||||
display: 'none'
|
||||
}}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
border: '1px solid black',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showDebugControls && (
|
||||
<>
|
||||
|
@ -208,6 +186,6 @@ export default function CropCanvas({imgSrc, aspect, triggerCropEventName, onCrop
|
|||
</>
|
||||
|
||||
)}
|
||||
</>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
|
||||
interface SingleEventListener<T extends Event> extends EventListener {
|
||||
(evt: T): void;
|
||||
}
|
||||
|
||||
export class CustomEvent {
|
||||
private name: string;
|
||||
|
||||
constructor(name: string){
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public addEventListener(listener: EventListenerOrEventListenerObject): void {
|
||||
document.addEventListener(this.name, listener);
|
||||
}
|
||||
|
||||
public removeEventListener(listener: EventListenerOrEventListenerObject): void {
|
||||
document.removeEventListener(this.name, listener);
|
||||
}
|
||||
|
||||
public dispatchEvent() {
|
||||
document.dispatchEvent(new Event(this.name))
|
||||
}
|
||||
}
|
||||
|
||||
interface EventProcessor<TResult>{
|
||||
(): Promise<TResult[]>
|
||||
}
|
||||
|
||||
interface ProcessorState<TResult> {
|
||||
listener: EventListener | undefined,
|
||||
result: Promise<TResult[]> | undefined,
|
||||
}
|
||||
|
||||
export class InvertedPromise<T> {
|
||||
private _inner: Promise<T> | null;
|
||||
private _resolve: ((value: T) => void) | null;
|
||||
private _reject: ((reason?: any) => void) | null;
|
||||
private _constructionFinished: Promise<void>;
|
||||
|
||||
public constructor() {
|
||||
this._resolve = null;
|
||||
this._reject = null;
|
||||
this._inner = null;
|
||||
this._constructionFinished = new Promise((constructionResolve) => {
|
||||
this._inner = new Promise((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
constructionResolve();
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public async inner(): Promise<T> {
|
||||
await this._constructionFinished;
|
||||
if (!this._inner){
|
||||
throw new Error("InvertedPromise._inner is null");
|
||||
}
|
||||
return await this._inner;
|
||||
}
|
||||
|
||||
public async resolve(value: T): Promise<void> {
|
||||
await this._constructionFinished;
|
||||
|
||||
if (!this._resolve){
|
||||
throw new Error("InvertedPromise._resolve is null");
|
||||
}
|
||||
|
||||
this._resolve(value);
|
||||
}
|
||||
|
||||
public async reject(value: T): Promise<void> {
|
||||
await this._constructionFinished;
|
||||
|
||||
if (!this._reject){
|
||||
throw new Error("InvertedPromise._reject is null");
|
||||
}
|
||||
|
||||
this._reject(value);
|
||||
}
|
||||
}
|
||||
|
||||
export class AccumulatingAwaitableEvent<TResult>{
|
||||
private processorMap: Map<EventProcessor<TResult>, ProcessorState<TResult>> = new Map();
|
||||
protected results: TResult[] = [];
|
||||
|
||||
private event: CustomEvent;
|
||||
|
||||
private resultCompletionPromise: InvertedPromise<TResult[]>;
|
||||
|
||||
constructor(name: string){
|
||||
this.event = new CustomEvent(name);
|
||||
this.resultCompletionPromise = new InvertedPromise<TResult[]>();
|
||||
}
|
||||
|
||||
|
||||
public addEventProcessor(processor: EventProcessor<TResult>): void {
|
||||
var state: ProcessorState<TResult> = {
|
||||
listener: undefined,
|
||||
result: undefined,
|
||||
};
|
||||
|
||||
if (this.processorMap.has(processor)) {
|
||||
throw new Error("This processor is already registered");
|
||||
}
|
||||
|
||||
const listener = () => {
|
||||
const result = processor();
|
||||
state.result = result;
|
||||
this.onResultPosted();
|
||||
}
|
||||
|
||||
state.listener = listener as EventListener;
|
||||
|
||||
this.event.addEventListener(listener as EventListener);
|
||||
|
||||
this.processorMap.set(processor, state);
|
||||
}
|
||||
|
||||
public removeEventProcessor(processor: EventProcessor<TResult>): void {
|
||||
const state = this.processorMap.get(processor);
|
||||
|
||||
if (!state) {
|
||||
throw new Error("This processor was not registered");
|
||||
}
|
||||
|
||||
if (!state.listener){
|
||||
throw new Error("This processor's state is not set");
|
||||
}
|
||||
|
||||
this.event.removeEventListener(state.listener);
|
||||
|
||||
// Remove cyclical reference so the state & listener can be gc'd :)
|
||||
state.listener = undefined;
|
||||
|
||||
this.processorMap.delete(processor);
|
||||
}
|
||||
|
||||
private async onResultPosted(): Promise<void> {
|
||||
// check if all processors have posted a result promise
|
||||
const resultsFound = [];
|
||||
for (const p of this.processorMap){
|
||||
if (p[1].result) {
|
||||
resultsFound.push(p[1].result);
|
||||
}
|
||||
}
|
||||
|
||||
if (resultsFound.length == this.processorMap.size) {
|
||||
// Await all of them.
|
||||
const awaitedResultLists = await Promise.all(resultsFound);
|
||||
const results = awaitedResultLists.flat();
|
||||
|
||||
// we have all our results. switch out the resultCompletionPromise for a fresh one, and clear the results.
|
||||
const oldResultCompletionPromise = this.resultCompletionPromise; // Anyone awaiting from here forward should await the next batch of results.
|
||||
this.resultCompletionPromise = new InvertedPromise();
|
||||
for (const p of this.processorMap){
|
||||
p[1].result = undefined;
|
||||
}
|
||||
|
||||
if (oldResultCompletionPromise){
|
||||
oldResultCompletionPromise.resolve(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public signalAllProcessors(): void {
|
||||
this.event.dispatchEvent();
|
||||
}
|
||||
|
||||
public waitForAllProcessors(): Promise<TResult[]> {
|
||||
return this.resultCompletionPromise.inner();
|
||||
}
|
||||
|
||||
public signalAndWaitForAllProcessors(): Promise<TResult[]> {
|
||||
this.signalAllProcessors();
|
||||
return this.waitForAllProcessors();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import { OutputImage } from "./CropCanvas";
|
||||
import { AccumulatingAwaitableEvent } from "./CustomAwaitableEvent";
|
||||
|
||||
export const ExportCropsEvent = new AccumulatingAwaitableEvent<OutputImage>("exportCropsRequested");
|
|
@ -0,0 +1,23 @@
|
|||
import { downloadZip, InputWithSizeMeta } from "client-zip";
|
||||
|
||||
|
||||
export interface FileToZip {
|
||||
name: string,
|
||||
blob: Blob,
|
||||
}
|
||||
|
||||
export async function downloadZipWithFiles(zipFilename: string, files: FileToZip[]){
|
||||
const filesWithMetadata: InputWithSizeMeta[] = files.map(f => {return {
|
||||
name: f.name,
|
||||
lastModified: new Date(),
|
||||
input: f.blob
|
||||
}});
|
||||
const zipBlob = await downloadZip(filesWithMetadata).blob();
|
||||
|
||||
// make and click a temporary link to download the Blob
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(zipBlob);
|
||||
link.download = zipFilename;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
|
||||
|
||||
|
||||
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
import { Paper, Title, Text, Slider } from '@mantine/core';
|
||||
import CropCanvas, { OutputImage } from './CropCanvas';
|
||||
import { ExportCropsEvent } from './Events';
|
||||
import { AccumulatingAwaitableEvent } from './CustomAwaitableEvent';
|
||||
|
||||
interface WallHeightControlProps {
|
||||
sectionLabel: string,
|
||||
helpLabel: string,
|
||||
imgSrc: string,
|
||||
tileWidth: number,
|
||||
tileHeight: number,
|
||||
outputFileLabel: string,
|
||||
accumulator: AccumulatingAwaitableEvent<OutputImage>,
|
||||
showDebugControls: boolean,
|
||||
}
|
||||
|
||||
export default function WallHeightCropControl({sectionLabel, helpLabel, imgSrc, tileWidth, tileHeight, outputFileLabel, accumulator, showDebugControls}: WallHeightControlProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const [numTiles, setNumTiles] = useState<number>(3)
|
||||
const smallCanvasRef = useRef<typeof CropCanvas>(null);
|
||||
|
||||
const outputWidth = (numTiles * tileWidth);
|
||||
const outputHeight = tileHeight;
|
||||
const aspectRatio = outputWidth / outputHeight;
|
||||
|
||||
const numTilesLabel = (value: number) => `${value} tile${value > 1 ? 's' : ''}`
|
||||
|
||||
return (
|
||||
<Paper shadow="xs" p="md">
|
||||
<Title order={3}>{sectionLabel}</Title>
|
||||
<Text fz="m">{helpLabel}</Text>
|
||||
|
||||
<Title order={5}>Number of tiles</Title>
|
||||
<Slider
|
||||
value={numTiles}
|
||||
onChange={setNumTiles}
|
||||
defaultValue={3}
|
||||
min={1}
|
||||
max={15}
|
||||
label={(numTilesLabel)}
|
||||
labelTransition="skew-down"
|
||||
labelTransitionDuration={150}
|
||||
labelTransitionTimingFunction="ease"
|
||||
step={1}
|
||||
|
||||
/>
|
||||
<Text fz="m">{`The selected area will span ${numTilesLabel(numTiles)} at this height. The resulting image will be ${outputWidth}px wide and ${outputHeight}px tall.`}</Text>
|
||||
|
||||
<CropCanvas
|
||||
imgSrc={imgSrc}
|
||||
aspect={aspectRatio}
|
||||
accumulator={accumulator}
|
||||
outputSpecs={[{
|
||||
width: outputWidth,
|
||||
height: outputHeight,
|
||||
name: `${outputFileLabel}`
|
||||
}]}
|
||||
showDebugControls={showDebugControls}
|
||||
/>
|
||||
</Paper>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue