working, needs cleanup still

This commit is contained in:
Vivian Lim 2023-04-22 01:38:07 -07:00
parent e2209d066b
commit b58016411e
12 changed files with 1675 additions and 14659 deletions

View File

@ -2,19 +2,19 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="apple-touch-icon" href="/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="manifest" href="/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
@ -29,6 +29,7 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.

15894
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,10 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.10.6",
"@mantine/core": "^6.0.8",
"@mantine/dropzone": "^6.0.8",
"@mantine/hooks": "^6.0.8",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
@ -14,15 +18,13 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-image-crop": "^10.0.9",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"start": "vite",
"build": "tsc && vite build",
"serve": "vite preview"
},
"eslintConfig": {
"extends": [
@ -41,5 +43,12 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"vite": "^4.3.1",
"vite-plugin-svgr": "^2.4.0",
"vite-tsconfig-paths": "^4.2.0"
}
}

View File

@ -1,4 +1,5 @@
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'
@ -15,6 +16,25 @@ import { canvasPreview } from './canvasPreview'
import { useDebounceEffect } from 'ahooks';
import 'react-image-crop/dist/ReactCrop.css'
import { Button, Container, FileButton, Grid, NativeSelect, NumberInput, TextInput } from '@mantine/core';
import { canvasScruncher } from './canvasScruncher';
const wallChoices = ["small", "medium", "tall"];
const wallSizes = {
small: 768,
medium: 1024,
tall: 1280
}
const wallTileWidth = 256;
interface WallDimensions {
height: number,
totalWidth: number,
tileCount: number,
diffuseUvScale: number,
}
// This is to demonstate how to make and center a % aspect crop
// which is a bit trickier so we use some helper functions.
@ -40,7 +60,8 @@ function centerAspectCrop(
export default function App() {
const [imgSrc, setImgSrc] = useState('')
const previewCanvasRef = useRef<HTMLCanvasElement>(null)
const fullWidthCanvasRef = useRef<HTMLCanvasElement>(null)
const scrunchedCanvasRef = useRef<HTMLCanvasElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const hiddenAnchorRef = useRef<HTMLAnchorElement>(null)
const blobUrlRef = useRef('')
@ -49,17 +70,12 @@ export default function App() {
const [scale, setScale] = useState(1)
const [rotate, setRotate] = useState(0)
const [aspect, setAspect] = useState<number | undefined>(16 / 9)
function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files && e.target.files.length > 0) {
setCrop(undefined) // Makes crop preview update between images.
const reader = new FileReader()
reader.addEventListener('load', () =>
setImgSrc(reader.result?.toString() || ''),
)
reader.readAsDataURL(e.target.files[0])
}
}
const [selectedWallSize, setWallSize] = useState(wallChoices[0])
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 [fullWidthPixelCrop, setFullWidthPixelCrop] = useState<PixelCrop>()
const [scrunchedPixelCrop, setScrunchedPixelCrop] = useState<PixelCrop>()
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
if (aspect) {
@ -68,12 +84,8 @@ export default function App() {
}
}
function onDownloadCropClick() {
if (!previewCanvasRef.current) {
throw new Error('Crop canvas does not exist')
}
previewCanvasRef.current.toBlob((blob) => {
function downloadCanvas(canvas: HTMLCanvasElement) {
canvas.toBlob((blob) => {
if (!blob) {
throw new Error('Failed to create blob')
}
@ -86,73 +98,180 @@ export default function App() {
})
}
function onDownloadFullWidthClick() {
if (!fullWidthCanvasRef.current) {
throw new Error('Crop canvas does not exist')
}
downloadCanvas(fullWidthCanvasRef.current);
}
function onDownloadScrunchedClick() {
if (!scrunchedCanvasRef.current) {
throw new Error('Crop canvas does not exist')
}
downloadCanvas(scrunchedCanvasRef.current);
}
useDebounceEffect(
() => {
if (
completedCrop?.width &&
completedCrop?.height &&
fullWidthPixelCrop?.width &&
fullWidthPixelCrop?.height &&
imgRef.current &&
previewCanvasRef.current
fullWidthCanvasRef.current
) {
// We use canvasPreview as it's much faster than imgPreview.
canvasPreview(
canvasScruncher(
imgRef.current,
previewCanvasRef.current,
completedCrop,
scale,
fullWidthCanvasRef.current,
fullWidthPixelCrop,
dimensions.totalWidth,
dimensions.totalWidth,
dimensions.height,
rotate,
)
}
},
[completedCrop, scale, rotate],
[fullWidthPixelCrop, scale, rotate, crop, dimensions],
{
wait: 100,
}
)
function handleToggleAspectClick() {
if (aspect) {
setAspect(undefined)
} else if (imgRef.current) {
const { width, height } = imgRef.current
setAspect(16 / 9)
setCrop(centerAspectCrop(width, height, 16 / 9))
useDebounceEffect(
() => {
if (
scrunchedPixelCrop?.width &&
scrunchedPixelCrop?.height &&
imgRef.current &&
scrunchedCanvasRef.current
) {
// We use canvasPreview as it's much faster than imgPreview.
canvasScruncher(
imgRef.current,
scrunchedCanvasRef.current,
scrunchedPixelCrop,
dimensions.totalWidth,
dimensions.totalWidth / dimensions.tileCount,
dimensions.height,
rotate,
)
}
},
[fullWidthPixelCrop, scale, rotate, crop, dimensions],
{
wait: 100,
}
}
)
React.useEffect(() => {
var height: number | undefined;
switch(selectedWallSize) {
case 'small':
height = wallSizes.small;
break;
case 'medium':
height = wallSizes.medium;
break;
case 'tall':
height = wallSizes.tall;
break;
default:
break;
}
if (height === undefined){
throw new Error("unknown wall size");
}
if (!(Number.isInteger(numTiles))){
throw new Error("numtiles isn't set");
}
var newDimensions: WallDimensions = {
height: height,
tileCount: numTiles as number,
totalWidth: wallTileWidth * (numTiles as number),
diffuseUvScale: 1.0 / (numTiles as number),
}
setDimensions(newDimensions);
if (imgRef.current){
const { width, height } = imgRef.current
var aspect = newDimensions.totalWidth / newDimensions.height;
setAspect(aspect);
var newCrop = centerAspectCrop(width, height, aspect);
setCrop(newCrop);
}
}, [numTiles, selectedWallSize])
React.useEffect(() => {
if (inputFile !== null){
setCrop(undefined) // Makes crop preview update between images.
const reader = new FileReader()
reader.addEventListener('load', () =>
setImgSrc(reader.result?.toString() || ''),
)
reader.readAsDataURL(inputFile)
}
}, [inputFile])
React.useEffect(() => {
setFullWidthPixelCrop(completedCrop);
setScrunchedPixelCrop(completedCrop);
}, [completedCrop, dimensions])
return (
<div className="App">
<div className="Crop-Controls">
<input type="file" accept="image/*" onChange={onSelectFile} />
<div>
<label htmlFor="scale-input">Scale: </label>
<input
id="scale-input"
type="number"
step="0.1"
value={scale}
disabled={!imgSrc}
onChange={(e) => setScale(Number(e.target.value))}
/>
</div>
<div>
<label htmlFor="rotate-input">Rotate: </label>
<input
id="rotate-input"
type="number"
value={rotate}
disabled={!imgSrc}
onChange={(e) =>
setRotate(Math.min(180, Math.max(-180, Number(e.target.value))))
}
/>
</div>
<div>
<button onClick={handleToggleAspectClick}>
Toggle aspect {aspect ? 'off' : 'on'}
</button>
</div>
</div>
<ThemeProvider>
<Grid>
<Grid.Col span={2}>
<FileButton
onChange={setInputFile}
accept="image/png,image.jpeg">
{(props) => <Button {...props}>Upload image</Button>}
</FileButton>
<NativeSelect
label="Wall height"
data={wallChoices}
value={selectedWallSize}
onChange={(event) => setWallSize(event.currentTarget.value)}
/>
<NumberInput
value={numTiles}
label="Number of tiles"
onChange={setNumTiles}
/>
<NumberInput
value={dimensions.totalWidth}
label="total width"
readOnly={true}
/>
<TextInput
value={dimensions.diffuseUvScale}
label="DiffuseUVScale"
readOnly={true}
/>
<div>
<Button onClick={onDownloadFullWidthClick}>Download fullWidth</Button>
<Button onClick={onDownloadScrunchedClick}>Download scrunched</Button>
<a
ref={hiddenAnchorRef}
download
style={{
position: 'absolute',
top: '-200vh',
visibility: 'hidden',
}}
>
Hidden download
</a>
</div>
</Grid.Col>
<Grid.Col span={6}>
{!!imgSrc && (
<ReactCrop
crop={crop}
@ -169,35 +288,28 @@ export default function App() {
/>
</ReactCrop>
)}
{!!completedCrop && (
{!!fullWidthPixelCrop && !!scrunchedPixelCrop && (
<>
<div>
<canvas
ref={previewCanvasRef}
ref={fullWidthCanvasRef}
style={{
border: '1px solid black',
objectFit: 'contain',
width: completedCrop.width,
height: completedCrop.height,
}}
/>
</div>
<div>
<button onClick={onDownloadCropClick}>Download Crop</button>
<a
ref={hiddenAnchorRef}
download
<canvas
ref={scrunchedCanvasRef}
style={{
position: 'absolute',
top: '-200vh',
visibility: 'hidden',
border: '1px solid black',
}}
>
Hidden download
</a>
/>
</div>
</>
)}
</div>
</Grid.Col>
</Grid>
</ThemeProvider>
)
}

17
src/ThemeProvider.tsx Normal file
View File

@ -0,0 +1,17 @@
import { MantineProvider, MantineThemeOverride } from '@mantine/core';
export const theme: MantineThemeOverride = {
colorScheme: 'dark',
};
interface ThemeProviderProps {
children: React.ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
return (
<MantineProvider withGlobalStyles withNormalizeCSS theme={theme}>
{children}
</MantineProvider>
);
}

73
src/canvasScruncher.ts Normal file
View File

@ -0,0 +1,73 @@
import { PixelCrop } from 'react-image-crop'
const TO_RADIANS = Math.PI / 180
export async function canvasScruncher(
image: HTMLImageElement,
canvas: HTMLCanvasElement,
crop: PixelCrop,
inputWidth: number,
targetWidth: number,
height: number,
rotate = 0,
) {
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('No 2d context')
}
const scaleX = image.naturalWidth / image.width
const scaleY = image.naturalHeight / image.height
// devicePixelRatio slightly increases sharpness on retina devices
// at the expense of slightly slower render times and needing to
// size the image back down if you want to download/upload and be
// true to the images natural size.
const pixelRatio = window.devicePixelRatio
// const pixelRatio = 1
canvas.width = targetWidth;
canvas.height = height;
ctx.scale(pixelRatio, pixelRatio)
//ctx.scale(1, 1);
ctx.imageSmoothingQuality = 'high'
const cropX = crop.x * scaleX
const cropY = crop.y * scaleY
const rotateRads = rotate * TO_RADIANS
const centerX = image.naturalWidth / 2
const centerY = image.naturalHeight / 2
ctx.save()
const finalScaleX = targetWidth / inputWidth;
const imageXScale = image.width / image.naturalWidth;
const imageYScale = image.height / image.naturalHeight;
/*
// 5) Move the crop origin to the canvas origin (0,0)
ctx.translate(-cropX, -cropY)
// 4) Move the origin to the center of the original position
ctx.translate(centerX, centerY)
// 2) Scale the image
ctx.scale(finalScaleX, 1)
// 1) Move the center of the image to the origin (0,0)
ctx.translate(-centerX, -centerY)
*/
ctx.drawImage(
image,
crop.x / imageXScale,
crop.y / imageYScale,
crop.width / imageXScale,
crop.height / imageYScale,
0,
0,
targetWidth,
height,
)
ctx.restore()
}

View File

@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

View File

@ -1 +0,0 @@
/// <reference types="react-scripts" />

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

14
tailwind.config.js Normal file
View File

@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('@headlessui/tailwindcss')
],
}

View File

@ -1,26 +1,21 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"types": ["vite/client", "vite-plugin-svgr/client"],
"allowJs": false,
"skipLibCheck": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
"include": ["src"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});