forked from LHRUN/paint-board
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
13 changed files
with
426 additions
and
34 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
src/components/boardOperation/downloadImage/canvasPreview.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import { Crop } from 'react-image-crop' | ||
|
||
const TO_RADIANS = Math.PI / 180 | ||
|
||
export async function canvasPreview( | ||
image: HTMLImageElement, | ||
canvas: HTMLCanvasElement, | ||
crop: Crop, | ||
scale = 1, | ||
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 | ||
const pixelRatio = window.devicePixelRatio ?? 1 | ||
|
||
canvas.width = Math.floor(crop.width * scaleX * pixelRatio) | ||
canvas.height = Math.floor(crop.height * scaleY * pixelRatio) | ||
|
||
ctx.scale(pixelRatio, pixelRatio) | ||
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() | ||
|
||
// 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) | ||
// 3) Rotate around the origin | ||
ctx.rotate(rotateRads) | ||
// 2) Scale the image | ||
ctx.scale(scale, scale) | ||
// 1) Move the center of the image to the origin (0,0) | ||
ctx.translate(-centerX, -centerY) | ||
ctx.drawImage( | ||
image, | ||
0, | ||
0, | ||
image.naturalWidth, | ||
image.naturalHeight, | ||
0, | ||
0, | ||
image.naturalWidth, | ||
image.naturalHeight | ||
) | ||
|
||
ctx.restore() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,249 @@ | ||
import { useState, FC, useRef } from 'react' | ||
import { useTranslation } from 'react-i18next' | ||
import { useDebounceEffect } from '@/hooks/useDebounceEffect' | ||
|
||
import ReactCrop, { Crop } from 'react-image-crop' | ||
import { canvasPreview } from './canvasPreview' | ||
|
||
import Mask from '@/components/mask' | ||
import ImageRotate from '@/components/icons/boardOperation/image-rotate.svg?react' | ||
import ImageScale from '@/components/icons/boardOperation/image-scale.svg?react' | ||
|
||
import 'react-image-crop/dist/ReactCrop.css' | ||
|
||
interface IProps { | ||
url: string | ||
showModal: boolean | ||
setShowModal: (show: boolean) => void | ||
} | ||
|
||
const DownloadImage: FC<IProps> = ({ url, showModal, setShowModal }) => { | ||
const { t } = useTranslation() | ||
const [saveImageRotate, updateSaveImageRotate] = useState(0) | ||
const [saveImageScale, updateSaveImageScale] = useState(1) | ||
|
||
const [completedCrop, setCompletedCrop] = useState<Crop | undefined>() | ||
const [crop, setCrop] = useState<Crop | undefined>() | ||
|
||
const imgRef = useRef<HTMLImageElement>(null) | ||
const previewCanvasRef = useRef<HTMLCanvasElement>(null) | ||
const hiddenAnchorRef = useRef<HTMLAnchorElement>(null) | ||
const blobUrlRef = useRef('') | ||
|
||
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => { | ||
const { width, height } = e.currentTarget | ||
const crop: Crop = { | ||
unit: 'px', | ||
x: 0.1 * width, | ||
y: 0.1 * height, | ||
width: 0.8 * width, | ||
height: 0.8 * height | ||
} | ||
setCrop({ ...crop }) | ||
setCompletedCrop({ ...crop }) | ||
} | ||
|
||
const handleReset = () => { | ||
if (imgRef.current) { | ||
const { width, height } = imgRef.current | ||
const crop: Crop = { | ||
unit: 'px', | ||
x: 0.1 * width, | ||
y: 0.1 * height, | ||
width: 0.8 * width, | ||
height: 0.8 * height | ||
} | ||
setCrop({ ...crop }) | ||
setCompletedCrop({ ...crop }) | ||
} | ||
updateSaveImageRotate(0) | ||
updateSaveImageScale(1) | ||
} | ||
|
||
const onDownloadCropClick = async () => { | ||
const image = imgRef.current | ||
const previewCanvas = previewCanvasRef.current | ||
if (!image || !previewCanvas || !completedCrop) { | ||
throw new Error('Crop canvas does not exist') | ||
} | ||
|
||
const scaleX = image.naturalWidth / image.width | ||
const scaleY = image.naturalHeight / image.height | ||
|
||
const offscreen = new OffscreenCanvas( | ||
completedCrop.width * scaleX, | ||
completedCrop.height * scaleY | ||
) | ||
const ctx = offscreen.getContext('2d') | ||
if (!ctx) { | ||
throw new Error('No 2d context') | ||
} | ||
|
||
ctx.drawImage( | ||
previewCanvas, | ||
0, | ||
0, | ||
previewCanvas.width, | ||
previewCanvas.height, | ||
0, | ||
0, | ||
offscreen.width, | ||
offscreen.height | ||
) | ||
|
||
// or { type: "image/jpeg", quality: <0 to 1> } | ||
const blob = await offscreen.convertToBlob({ | ||
type: 'image/png' | ||
}) | ||
|
||
if (blobUrlRef.current) { | ||
URL.revokeObjectURL(blobUrlRef.current) | ||
} | ||
blobUrlRef.current = URL.createObjectURL(blob) | ||
|
||
if (hiddenAnchorRef.current) { | ||
hiddenAnchorRef.current.href = blobUrlRef.current | ||
hiddenAnchorRef.current.click() | ||
} | ||
} | ||
|
||
useDebounceEffect( | ||
async () => { | ||
if ( | ||
completedCrop?.width && | ||
completedCrop?.height && | ||
imgRef.current && | ||
previewCanvasRef.current | ||
) { | ||
canvasPreview( | ||
imgRef.current, | ||
previewCanvasRef.current, | ||
completedCrop, | ||
saveImageScale, | ||
saveImageRotate | ||
) | ||
} | ||
}, | ||
100, | ||
[completedCrop, saveImageScale, saveImageRotate] | ||
) | ||
|
||
return ( | ||
<Mask | ||
show={showModal} | ||
clickMask={() => { | ||
setShowModal(false) | ||
}} | ||
> | ||
<div className="p-6 bg-[#eef1ff] card shadow-xl overflow-auto max-w-[800px] w-[80vw] h-fit max-h-[60vh]"> | ||
{completedCrop && ( | ||
<div className="w-full flex justify-between"> | ||
<div className="w-[48%]"> | ||
<div className="flex items-center"> | ||
<ImageRotate className="mr-[6px] shrink-0" /> | ||
<input | ||
className="range range-primary range-xs" | ||
type="range" | ||
min="0" | ||
max="360" | ||
step="1" | ||
value={String(saveImageRotate)} | ||
onChange={(e) => { | ||
updateSaveImageRotate(Number(e.target.value)) | ||
}} | ||
/> | ||
</div> | ||
|
||
<div className="flex items-center mt-3"> | ||
<ImageScale className="mr-[12px] shrink-0" /> | ||
<input | ||
className="range range-primary range-xs" | ||
type="range" | ||
min="0.2" | ||
max="1.5" | ||
step="0.1" | ||
value={String(saveImageScale)} | ||
onChange={(e) => { | ||
updateSaveImageScale(Number(e.target.value)) | ||
}} | ||
/> | ||
</div> | ||
</div> | ||
|
||
<div className="w-[48%] flex flex-wrap gap-4"> | ||
<button | ||
className="btn btn-ghost btn-outline btn-sm" | ||
onClick={() => setShowModal(false)} | ||
> | ||
{t('cancel')} | ||
</button> | ||
<button | ||
className="btn btn-secondary btn-sm" | ||
onClick={handleReset} | ||
> | ||
{t('reset')} | ||
</button> | ||
<button | ||
className="btn btn-primary btn-sm" | ||
onClick={onDownloadCropClick} | ||
> | ||
{t('download')} | ||
</button> | ||
|
||
<a | ||
href="#hidden" | ||
ref={hiddenAnchorRef} | ||
download="paint-board" | ||
style={{ | ||
position: 'absolute', | ||
top: '-200vh', | ||
visibility: 'hidden' | ||
}} | ||
> | ||
Hidden download | ||
</a> | ||
</div> | ||
</div> | ||
)} | ||
<div className="w-full flex justify-between mt-3"> | ||
{url && ( | ||
<div className="w-[48%] shrink-0"> | ||
<div className="w-fit bg-transparent bg-[length:13px_13px] bg-white flex"> | ||
<ReactCrop | ||
crop={crop} | ||
onChange={setCrop} | ||
onComplete={setCompletedCrop} | ||
minHeight={100} | ||
minWidth={100} | ||
> | ||
<img | ||
ref={imgRef} | ||
src={url} | ||
style={{ | ||
transform: `scale(${saveImageScale}) rotate(${saveImageRotate}deg)` | ||
}} | ||
onLoad={onImageLoad} | ||
/> | ||
</ReactCrop> | ||
</div> | ||
</div> | ||
)} | ||
{completedCrop && ( | ||
<div className="w-[48%] shrink-0"> | ||
<canvas | ||
ref={previewCanvasRef} | ||
className="object-contain border border-base-content border-dashed" | ||
style={{ | ||
width: completedCrop.width, | ||
height: completedCrop.height | ||
}} | ||
/> | ||
</div> | ||
)} | ||
</div> | ||
</div> | ||
</Mask> | ||
) | ||
} | ||
|
||
export default DownloadImage |
Oops, something went wrong.