mirror of
https://github.com/voltbonn/profile-picture-generator.git
synced 2024-12-22 07:45:04 +00:00
added basic editing (moving + scaling)
it doesn't yet work on touch devinces
This commit is contained in:
parent
f70c295da3
commit
2e6795f79f
7 changed files with 421 additions and 57 deletions
|
@ -7,6 +7,8 @@
|
|||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"hammerjs": "^2.0.8",
|
||||
"hamsterjs": "^1.1.3",
|
||||
"merge-images": "^2.0.0",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
|
|
7
public/hammer.min.js
vendored
Normal file
7
public/hammer.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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="description"
|
||||
|
@ -24,6 +24,8 @@
|
|||
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`.
|
||||
-->
|
||||
|
||||
<script src="./%PUBLIC_URL%/hammer.min.js"></script>
|
||||
<title>Volt Social Media Frame Generator</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
30
src/App.css
30
src/App.css
|
@ -13,11 +13,14 @@
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body{
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
|
||||
background-color: var(--background);
|
||||
min-height: 100vh;
|
||||
/* min-height: 100vh; */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
@ -117,3 +120,26 @@ button:hover,
|
|||
.droparea.active{
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
189
src/App.js
189
src/App.js
|
@ -1,7 +1,8 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import './App.css'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import FrameChooser from './FrameChooser.js'
|
||||
import Editor from './Editor.js'
|
||||
import HeaderImage from './HeaderImage.svg'
|
||||
|
||||
import mergeImages from 'merge-images'
|
||||
|
@ -53,16 +54,37 @@ function getOrientation(file, callback) {
|
|||
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() {
|
||||
const [frameURL, setFrameURL] = useState(null)
|
||||
const [originalPhoto, setOriginalPhoto] = useState(null)
|
||||
const [photo, setPhoto] = useState(null)
|
||||
const [combinedImage, setCombinedImage] = useState(null)
|
||||
const [originalPhotoRation, setOriginalPhotoRation] = useState(1)
|
||||
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 => {
|
||||
setFrameURL(newFrameURL)
|
||||
}, [setFrameURL])
|
||||
|
||||
const handleCordsChange = useCallback(({x, y, scale}) => {
|
||||
console.log({ x, y, scale })
|
||||
setCords({ x, y, scale })
|
||||
}, [])
|
||||
|
||||
const handleReadFile = useCallback(file => {
|
||||
if (!(!!file)) {
|
||||
|
@ -73,12 +95,6 @@ function App() {
|
|||
reader.onload = reader_event => {
|
||||
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 })
|
||||
|
||||
let width, height;
|
||||
if (img.width < img.height) {
|
||||
height = (img.height / img.width) * frameSize
|
||||
|
@ -88,7 +104,74 @@ function App() {
|
|||
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
|
||||
switch (orientation) {
|
||||
// Source: https://stackoverflow.com/a/30242954/2387277
|
||||
|
@ -133,31 +216,48 @@ function App() {
|
|||
break
|
||||
}
|
||||
|
||||
|
||||
const width_scaled = width * cords.scale
|
||||
const height_scaled = height * cords.scale
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
(frameSize - width) / 2,
|
||||
(frameSize - height) / 2,
|
||||
width,
|
||||
height,
|
||||
cords.x * 3.5 + (frameSize - width_scaled) * 0.5,
|
||||
cords.y * 3.5 + (frameSize - height_scaled) * 0.5,
|
||||
width_scaled,
|
||||
height_scaled,
|
||||
)
|
||||
// ctx.drawImage(
|
||||
// img,
|
||||
// ((frameSize - width_scaled) * 0.5),
|
||||
// ((frameSize - height_scaled) * 0.5),
|
||||
// width_scaled,
|
||||
// height_scaled,
|
||||
// )
|
||||
|
||||
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])
|
||||
}, [handleReadFile])
|
||||
|
||||
const onDrop = useCallback(acceptedFiles => {
|
||||
handleReadFile(acceptedFiles[0])
|
||||
}, [handleReadFile])
|
||||
}
|
||||
img.src = originalPhoto
|
||||
}, [
|
||||
originalPhoto,
|
||||
cords.x,
|
||||
cords.y,
|
||||
cords.scale,
|
||||
orientation,
|
||||
frameURL,
|
||||
height,
|
||||
width,
|
||||
])
|
||||
|
||||
const { isDragActive, getRootProps } = useDropzone({
|
||||
onDrop,
|
||||
|
@ -167,15 +267,6 @@ function App() {
|
|||
})
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
mergeImages([
|
||||
...(photo ? [photo] : []),
|
||||
...(frameURL ? [frameURL] : []),
|
||||
])
|
||||
.then(b64 => setCombinedImage(b64))
|
||||
}, [photo, frameURL])
|
||||
|
||||
|
||||
return (
|
||||
<div className="App" {...getRootProps()}>
|
||||
<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>
|
||||
|
||||
<label className="labelButton" tabIndex="0" style={{outline:'none'}}>
|
||||
{!!photo ? <img src={originalPhoto} alt="Preview" /> : null}
|
||||
<span>{!!photo ? 'Change Photo' : 'Load Photo'}</span>
|
||||
{!!originalPhoto ? <img src={originalPhoto} alt="Preview" /> : null}
|
||||
<span>{!!originalPhoto ? 'Change Photo' : 'Load Photo'}</span>
|
||||
<input onChange={handleImage} type="file" accept="image/*" style={{display: 'none'}} />
|
||||
</label>
|
||||
|
||||
{!!originalPhoto ? (<>
|
||||
<FrameChooser onFrameChange={handleFrameURL} />
|
||||
</>) : null}
|
||||
|
||||
<h2>Download your Photo:</h2>
|
||||
<img src={combinedImage} className="FinishedFrame" alt="Finished Frame" />
|
||||
<a download="volt-profile-picture.png" href={combinedImage} target="_blank" rel="noreferrer">
|
||||
<button>Download Profile Picture</button>
|
||||
</a>
|
||||
{!!originalPhoto && !!frameURL ? (<>
|
||||
<h2>Edit your Photo:</h2>
|
||||
<p>Your can reposition the image and scale it. Use pinch-to-zoom or scroll to scale.</p>
|
||||
|
||||
<Editor
|
||||
background={originalPhoto}
|
||||
backgroundRatio={originalPhotoRation}
|
||||
foreground={frameURL}
|
||||
onChange={handleCordsChange}
|
||||
/>
|
||||
|
||||
<button onClick={handleDownload}>Download Profile Picture</button>
|
||||
</>) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
216
src/Editor.js
Normal file
216
src/Editor.js
Normal 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
|
10
yarn.lock
10
yarn.lock
|
@ -5305,6 +5305,16 @@ gzip-size@5.1.1:
|
|||
duplexer "^0.1.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:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"
|
||||
|
|
Loading…
Reference in a new issue