Skip to content

Commit

Permalink
feat: add download custom config
Browse files Browse the repository at this point in the history
  • Loading branch information
LHRUN committed Oct 27, 2024
1 parent 0671831 commit 79bf1bf
Show file tree
Hide file tree
Showing 13 changed files with 426 additions and 34 deletions.
4 changes: 2 additions & 2 deletions src/components/boardOperation/deleteFileModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ const DeleteFileModal = () => {
className="btn btn-active btn-primary btn-sm w-2/5"
onClick={deleteCurrentFile}
>
{t('deleteFileModal.confirm')}
{t('confirm')}
</label>
<label
htmlFor="delete-file-modal"
className="btn btn-active btn-ghost btn-sm w-2/5"
>
{t('deleteFileModal.cancel')}
{t('cancel')}
</label>
</div>
</label>
Expand Down
62 changes: 62 additions & 0 deletions src/components/boardOperation/downloadImage/canvasPreview.ts
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()
}
249 changes: 249 additions & 0 deletions src/components/boardOperation/downloadImage/index.tsx
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
Loading

0 comments on commit 79bf1bf

Please sign in to comment.