1
0
Fork 0
mirror of https://github.com/voltbonn/profile-picture-generator.git synced 2024-12-22 15:55:08 +00:00

added basic editing (moving + scaling)

it doesn't yet work on touch devinces
This commit is contained in:
thomasrosen 2021-01-23 12:50:39 +01:00
parent f70c295da3
commit 2e6795f79f
7 changed files with 421 additions and 57 deletions

View file

@ -7,6 +7,8 @@
"@testing-library/jest-dom": "^5.11.4", "@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^12.1.10",
"hammerjs": "^2.0.8",
"hamsterjs": "^1.1.3",
"merge-images": "^2.0.0", "merge-images": "^2.0.0",
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",

7
public/hammer.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/volt-logo-64.png" /> <link rel="icon" href="%PUBLIC_URL%/volt-logo-64.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
@ -24,6 +24,8 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<script src="./%PUBLIC_URL%/hammer.min.js"></script>
<title>Volt Social Media Frame Generator</title> <title>Volt Social Media Frame Generator</title>
</head> </head>
<body> <body>

View file

@ -13,11 +13,14 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
body{
background-color: var(--background);
}
.App { .App {
text-align: center; text-align: center;
background-color: var(--background); /* min-height: 100vh; */
min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -117,3 +120,26 @@ button:hover,
.droparea.active{ .droparea.active{
opacity: 1; opacity: 1;
} }
.Editor{
pointer-events: all;
position: relative;
margin: 2vh;
font-size: 0;
cursor: move;
overflow: hidden;
}
.Editor .background{
pointer-events: none;
position: absolute;
top: 50%;
left: 50%;
}
.Editor .foreground{
pointer-events: none;
position: relative;
width: 36vh;
height: 36vh;
}

View file

@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useCallback } from 'react'
import './App.css' import './App.css'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import FrameChooser from './FrameChooser.js' import FrameChooser from './FrameChooser.js'
import Editor from './Editor.js'
import HeaderImage from './HeaderImage.svg' import HeaderImage from './HeaderImage.svg'
import mergeImages from 'merge-images' import mergeImages from 'merge-images'
@ -53,16 +54,37 @@ function getOrientation(file, callback) {
reader.readAsArrayBuffer(file.slice(0, 64 * 1024)) reader.readAsArrayBuffer(file.slice(0, 64 * 1024))
} }
function trigger_download(name, data){
const a = document.createElement('a')
document.body.appendChild(a)
a.target = '_blank'
a.download = name
a.href = data // "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAYAAAAmL5yKAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAABWSURBVDhPY0xISPh//0UOA7mAiVyNMH2jBjAwkBQGjD9KGBTEJ6OEO0kG2NvbMwCjnXwDsEU5SS5ANuDhjRCGJbPFSQsDdBfIyMhQZgDIQLK9QLWkDABPsQw5I+5qmAAAAABJRU5ErkJggg==";
a.click()
}
function App() { function App() {
const [frameURL, setFrameURL] = useState(null) const [frameURL, setFrameURL] = useState(null)
const [originalPhoto, setOriginalPhoto] = useState(null) const [originalPhoto, setOriginalPhoto] = useState(null)
const [photo, setPhoto] = useState(null) const [originalPhotoRation, setOriginalPhotoRation] = useState(1)
const [combinedImage, setCombinedImage] = useState(null) const [orientation, set_orientation] = useState(null)
// const [combinedImage, set_combinedImage] = useState(null)
const [width, set_width] = useState(0)
const [height, set_height] = useState(0)
const [cords, setCords] = useState({x:0, y:0, scale:1})
const handleFrameURL = useCallback(newFrameURL => { const handleFrameURL = useCallback(newFrameURL => {
setFrameURL(newFrameURL) setFrameURL(newFrameURL)
}, [setFrameURL]) }, [setFrameURL])
const handleCordsChange = useCallback(({x, y, scale}) => {
console.log({ x, y, scale })
setCords({ x, y, scale })
}, [])
const handleReadFile = useCallback(file => { const handleReadFile = useCallback(file => {
if (!(!!file)) { if (!(!!file)) {
@ -73,12 +95,6 @@ function App() {
reader.onload = reader_event => { reader.onload = reader_event => {
const img = new Image() const img = new Image()
img.onload = function () { img.onload = function () {
const canvas = document.createElement('canvas')
canvas.width = frameSize
canvas.height = frameSize
const ctx = canvas.getContext('2d', { alpha: true })
let width, height; let width, height;
if (img.width < img.height) { if (img.width < img.height) {
height = (img.height / img.width) * frameSize height = (img.height / img.width) * frameSize
@ -88,7 +104,74 @@ function App() {
width = (img.width / img.height) * frameSize width = (img.width / img.height) * frameSize
} }
getOrientation(file, orientation => { getOrientation(file, new_orientation => {
let original_ration = 1
// use the correct image orientation
switch (new_orientation) {
// Source: https://stackoverflow.com/a/30242954/2387277
// Source: https://stackoverflow.com/questions/19463126/how-to-draw-photo-with-correct-orientation-in-canvas-after-capture-photo-by-usin
case 2:
// horizontal flip
original_ration = height / width
break
case 3:
// 180° rotate left
original_ration = height / width
break
case 4:
// vertical flip
original_ration = height / width
break
case 5:
// vertical flip + 90 rotate right
original_ration = width / height
break
case 6:
// 90° rotate right
original_ration = width / height
break
case 7:
// horizontal flip + 90 rotate right
original_ration = width / height
break
case 8:
// 90° rotate left
original_ration = width / height
break
default:
original_ration = height / width
break
}
set_width(width)
set_height(height)
setOriginalPhoto(reader_event.target.result)
set_orientation(new_orientation)
setOriginalPhotoRation(original_ration)
})
}
img.src = reader_event.target.result
}
reader.readAsDataURL(file)
}, [])
const handleImage = useCallback(files_event => {
handleReadFile(files_event.target.files[0])
}, [handleReadFile])
const onDrop = useCallback(acceptedFiles => {
handleReadFile(acceptedFiles[0])
}, [handleReadFile])
const handleDownload = useCallback(() => {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
canvas.width = frameSize
canvas.height = frameSize
const ctx = canvas.getContext('2d', { alpha: true })
// use the correct image orientation // use the correct image orientation
switch (orientation) { switch (orientation) {
// Source: https://stackoverflow.com/a/30242954/2387277 // Source: https://stackoverflow.com/a/30242954/2387277
@ -133,31 +216,48 @@ function App() {
break break
} }
const width_scaled = width * cords.scale
const height_scaled = height * cords.scale
ctx.drawImage( ctx.drawImage(
img, img,
(frameSize - width) / 2, cords.x * 3.5 + (frameSize - width_scaled) * 0.5,
(frameSize - height) / 2, cords.y * 3.5 + (frameSize - height_scaled) * 0.5,
width, width_scaled,
height, height_scaled,
) )
// ctx.drawImage(
// img,
// ((frameSize - width_scaled) * 0.5),
// ((frameSize - height_scaled) * 0.5),
// width_scaled,
// height_scaled,
// )
const pngUrl = canvas.toDataURL() const pngUrl = canvas.toDataURL()
setPhoto(pngUrl)
mergeImages([
...(pngUrl ? [pngUrl] : []),
...(frameURL ? [frameURL] : []),
])
.then(b64 => {
// set_combinedImage(b64)
trigger_download('volt-profile-picture.png', b64)
}) })
}
img.src = reader_event.target.result
setOriginalPhoto(reader_event.target.result)
}
reader.readAsDataURL(file)
}, [])
const handleImage = useCallback(files_event => { }
handleReadFile(files_event.target.files[0]) img.src = originalPhoto
}, [handleReadFile]) }, [
originalPhoto,
const onDrop = useCallback(acceptedFiles => { cords.x,
handleReadFile(acceptedFiles[0]) cords.y,
}, [handleReadFile]) cords.scale,
orientation,
frameURL,
height,
width,
])
const { isDragActive, getRootProps } = useDropzone({ const { isDragActive, getRootProps } = useDropzone({
onDrop, onDrop,
@ -167,15 +267,6 @@ function App() {
}) })
useEffect(() => {
mergeImages([
...(photo ? [photo] : []),
...(frameURL ? [frameURL] : []),
])
.then(b64 => setCombinedImage(b64))
}, [photo, frameURL])
return ( return (
<div className="App" {...getRootProps()}> <div className="App" {...getRootProps()}>
<img src={HeaderImage} className="HeaderImage" alt="Volt Logo" /> <img src={HeaderImage} className="HeaderImage" alt="Volt Logo" />
@ -188,18 +279,28 @@ function App() {
<p>It should best be a square image or your face in the middle. The photo is not saved and never leaves your computer.</p> <p>It should best be a square image or your face in the middle. The photo is not saved and never leaves your computer.</p>
<label className="labelButton" tabIndex="0" style={{outline:'none'}}> <label className="labelButton" tabIndex="0" style={{outline:'none'}}>
{!!photo ? <img src={originalPhoto} alt="Preview" /> : null} {!!originalPhoto ? <img src={originalPhoto} alt="Preview" /> : null}
<span>{!!photo ? 'Change Photo' : 'Load Photo'}</span> <span>{!!originalPhoto ? 'Change Photo' : 'Load Photo'}</span>
<input onChange={handleImage} type="file" accept="image/*" style={{display: 'none'}} /> <input onChange={handleImage} type="file" accept="image/*" style={{display: 'none'}} />
</label> </label>
{!!originalPhoto ? (<>
<FrameChooser onFrameChange={handleFrameURL} /> <FrameChooser onFrameChange={handleFrameURL} />
</>) : null}
<h2>Download your Photo:</h2> {!!originalPhoto && !!frameURL ? (<>
<img src={combinedImage} className="FinishedFrame" alt="Finished Frame" /> <h2>Edit your Photo:</h2>
<a download="volt-profile-picture.png" href={combinedImage} target="_blank" rel="noreferrer"> <p>Your can reposition the image and scale it. Use pinch-to-zoom or scroll to scale.</p>
<button>Download Profile Picture</button>
</a> <Editor
background={originalPhoto}
backgroundRatio={originalPhotoRation}
foreground={frameURL}
onChange={handleCordsChange}
/>
<button onClick={handleDownload}>Download Profile Picture</button>
</>) : null}
</div> </div>
) )
} }

216
src/Editor.js Normal file
View file

@ -0,0 +1,216 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import Hammer from 'hammerjs'
import Hamster from 'hamsterjs'
function updateRange(imageWidth, imageHeight, imageScale, containerWidth, containerHeight) {
const rangeX = Math.max(0, (imageWidth * imageScale) - containerWidth)
const rangeY = Math.max(0, (imageHeight * imageScale) - containerHeight)
const rangeMaxX = (rangeX / 2)
const rangeMinX = 0 - rangeMaxX
const rangeMaxY = (rangeY / 2)
const rangeMinY = 0 - rangeMaxY
return {
rangeMaxX,
rangeMinX,
rangeMaxY,
rangeMinY,
}
}
function clamp(value, min, max) {
return Math.min(Math.max(min, value), max)
}
let minScale = 1;
let maxScale = 8;
function Editor({ onChange, background, backgroundRatio, foreground }) {
const editorRef = useRef(null)
const backgroundImageRef = useRef(null)
const [hammer_got_init, set_hammer_got_init] = useState(false)
const [hammertime, set_hammertime] = useState(null)
const [hamster, set_hamster] = useState(null)
const [x, set_x] = useState(0)
const [y, set_y] = useState(0)
const [add_x, set_add_x] = useState(0)
const [add_y, set_add_y] = useState(0)
const [scale, set_scale] = useState(1)
// const [add_scale, set_add_scale] = useState(0)
const [photoWidth, setPhotoWidth] = useState(300)
const [photoHeight, setPhotoHeight] = useState(300)
const [editorWidth, setEditorWidth] = useState(300)
const [editorHeight, setEditorHeight] = useState(300)
const [rangeMinX, set_rangeMinX] = useState(0)
const [rangeMinY, set_rangeMinY] = useState(0)
const [rangeMaxX, set_rangeMaxX] = useState(0)
const [rangeMaxY, set_rangeMaxY] = useState(0)
useEffect(() => {
if (!!onChange) {
onChange({ x, y, scale})
}
}, [onChange, x, y, scale])
useEffect(() => {
if (!!editorRef && !!editorRef.current) {
const new_editorWidth = editorRef.current.offsetWidth
const new_editorHeight = editorRef.current.offsetHeight
setEditorHeight(new_editorHeight)
setEditorWidth(new_editorWidth)
let new_photoWidth = 1
let new_photoHeight = 1
if (backgroundRatio < 1) {
new_photoWidth = 1 / backgroundRatio
} else if (backgroundRatio > 1) {
new_photoHeight = 1 * backgroundRatio
}
setPhotoWidth(new_photoWidth)
setPhotoHeight(new_photoHeight)
}
}, [
backgroundRatio,
foreground,
])
useEffect(() => {
const {
rangeMinX,
rangeMinY,
rangeMaxX,
rangeMaxY,
} = updateRange(photoWidth * editorWidth, photoHeight * editorHeight, scale, editorWidth, editorHeight)
set_rangeMinX(rangeMinX)
set_rangeMinY(rangeMinY)
set_rangeMaxX(rangeMaxX)
set_rangeMaxY(rangeMaxY)
}, [
photoWidth,
photoHeight,
editorWidth,
editorHeight,
scale,
])
useEffect(() => {
set_x(0)
set_y(0)
set_add_x(0)
set_add_y(0)
set_scale(1)
}, [background])
const handleMove = useCallback(event => {
const prev_x = event.target.dataset.x * 1
const prev_y = event.target.dataset.y * 1
const new_x = clamp(prev_x + event.deltaX, rangeMinX, rangeMaxX)
const new_y = clamp(prev_y + event.deltaY, rangeMinY, rangeMaxY)
if (event.isFinal) {
set_x(new_x || 0)
set_y(new_y || 0)
set_add_x(0)
set_add_y(0)
}else{
set_add_x(new_x - prev_x || 0)
set_add_y(new_y - prev_y || 0)
}
}, [
rangeMinX,
rangeMinY,
rangeMaxX,
rangeMaxY,
])
const handleScale = useCallback((event, delta, deltaX, deltaY) => {
event.preventDefault()
const prev_scale = event.target.dataset.scale * 1
const new_scale = clamp(prev_scale + delta / 200, minScale, maxScale)
set_scale(new_scale || 1)
const prev_x = event.target.dataset.x * 1
const prev_y = event.target.dataset.y * 1
set_x(clamp(prev_x, rangeMinX, rangeMaxX) || 0)
set_y(clamp(prev_y, rangeMinY, rangeMaxY) || 0)
}, [
rangeMinX,
rangeMinY,
rangeMaxX,
rangeMaxY,
])
useEffect(() => {
if (!hammer_got_init && !!editorRef && !!editorRef.current) {
const element = editorRef.current
element.addEventListener('mousedown', e => e.preventDefault(), false)
set_hammertime(new Hammer(element, {
direction: 'DIRECTION_ALL',
}))
set_hamster(Hamster(element))
set_hammer_got_init(true)
}
}, [editorRef, hammer_got_init])
useEffect(() => {
if (!!hammertime && !!hamster && hammer_got_init && !!editorRef && !!editorRef.current) {
hammertime.on('pan', handleMove)
hamster.wheel(handleScale)
return function () {
hammertime.off('pan', handleMove)
hamster.unwheel()
}
}
}, [editorRef, handleMove, handleScale, hammer_got_init, hammertime, hamster])
return (
<div
className="Editor"
ref={editorRef}
data-x={x}
data-y={y}
data-scale={scale}
>
<img
src={background}
ref={backgroundImageRef}
alt=""
className="background"
style={{
width: (photoWidth*100)+'%',
height: (photoHeight*100)+'%',
transform: `translate3d(calc(-50% + ${x + add_x}px), calc(-50% + ${y + add_y}px), 0) scale(${scale},${scale})`,
}}
/>
<img
src={foreground}
alt=""
className="foreground"
/>
</div>
)
}
export default Editor

View file

@ -5305,6 +5305,16 @@ gzip-size@5.1.1:
duplexer "^0.1.1" duplexer "^0.1.1"
pify "^4.0.1" pify "^4.0.1"
hammerjs@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1"
integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=
hamsterjs@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/hamsterjs/-/hamsterjs-1.1.3.tgz#4adc6b9100a3fcc7c65fc391debb249901a50df6"
integrity sha512-q4XBr7hnxx1WyZA8mpVDuZVa1YXaR0WZaFSBxnj8hUXltuqXJOt5yuWYkAbMXsj+q0REDUO990+/TuxEadXFyg==
handle-thing@^2.0.0: handle-thing@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"