working, needs cleanup still
This commit is contained in:
parent
e2209d066b
commit
b58016411e
|
@ -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.
|
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
276
src/App.tsx
276
src/App.tsx
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -1,3 +1,7 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="react-scripts" />
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -0,0 +1,14 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@headlessui/tailwindcss')
|
||||
],
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
Loading…
Reference in New Issue