creates a zip

This commit is contained in:
Vivian Lim 2023-04-23 00:47:28 -07:00
parent d62d3a5613
commit 6b852d0fa3
8 changed files with 387 additions and 78 deletions

6
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

179
src/CustomAwaitableEvent.ts Normal file
View File

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

4
src/Events.ts Normal file
View File

@ -0,0 +1,4 @@
import { OutputImage } from "./CropCanvas";
import { AccumulatingAwaitableEvent } from "./CustomAwaitableEvent";
export const ExportCropsEvent = new AccumulatingAwaitableEvent<OutputImage>("exportCropsRequested");

23
src/MakeZip.ts Normal file
View File

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

View File

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