feat: custom app icon (#7196)

Co-authored-by: crazywoola <427733928@qq.com>
This commit is contained in:
Hash Brown
2024-08-19 09:16:33 +08:00
committed by GitHub
parent a0c689c273
commit fbf31b5d52
65 changed files with 1068 additions and 352 deletions

View File

@@ -0,0 +1,97 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import { createRef, useEffect, useState } from 'react'
import type { Area } from 'react-easy-crop'
import Cropper from 'react-easy-crop'
import classNames from 'classnames'
import { ImagePlus } from '../icons/src/vender/line/images'
import { useDraggableUploader } from './hooks'
import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
type UploaderProps = {
className?: string
onImageCropped?: (tempUrl: string, croppedAreaPixels: Area, fileName: string) => void
}
const Uploader: FC<UploaderProps> = ({
className,
onImageCropped,
}) => {
const [inputImage, setInputImage] = useState<{ file: File; url: string }>()
useEffect(() => {
return () => {
if (inputImage)
URL.revokeObjectURL(inputImage.url)
}
}, [inputImage])
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const onCropComplete = async (_: Area, croppedAreaPixels: Area) => {
if (!inputImage)
return
onImageCropped?.(inputImage.url, croppedAreaPixels, inputImage.file.name)
}
const handleLocalFileInput = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file)
setInputImage({ file, url: URL.createObjectURL(file) })
}
const {
isDragActive,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
} = useDraggableUploader((file: File) => setInputImage({ file, url: URL.createObjectURL(file) }))
const inputRef = createRef<HTMLInputElement>()
return (
<div className={classNames(className, 'w-full px-3 py-1.5')}>
<div
className={classNames(
isDragActive && 'border-primary-600',
'relative aspect-square bg-gray-50 border-[1.5px] border-gray-200 border-dashed rounded-lg flex flex-col justify-center items-center text-gray-500')}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{
!inputImage
? <>
<ImagePlus className="w-[30px] h-[30px] mb-3 pointer-events-none" />
<div className="text-sm font-medium mb-[2px]">
<span className="pointer-events-none">Drop your image here, or&nbsp;</span>
<button className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>browse</button>
<input
ref={inputRef} type="file" className="hidden"
onClick={e => ((e.target as HTMLInputElement).value = '')}
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
onChange={handleLocalFileInput}
/>
</div>
<div className="text-xs pointer-events-none">Supports PNG, JPG, JPEG, WEBP and GIF</div>
</>
: <Cropper
image={inputImage.url}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
}
</div>
</div>
)
}
export default Uploader

View File

@@ -0,0 +1,43 @@
import { useCallback, useState } from 'react'
export const useDraggableUploader = <T extends HTMLElement>(setImageFn: (file: File) => void) => {
const [isDragActive, setIsDragActive] = useState(false)
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(true)
}, [])
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
const file = e.dataTransfer.files[0]
if (!file)
return
setImageFn(file)
}, [setImageFn])
return {
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
isDragActive,
}
}

View File

@@ -0,0 +1,139 @@
import type { FC } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Area } from 'react-easy-crop'
import Modal from '../modal'
import Divider from '../divider'
import Button from '../button'
import { ImagePlus } from '../icons/src/vender/line/images'
import { useLocalFileUploader } from '../image-uploader/hooks'
import EmojiPickerInner from '../emoji-picker/Inner'
import Uploader from './Uploader'
import s from './style.module.css'
import getCroppedImg from './utils'
import type { AppIconType, ImageFile } from '@/types/app'
import cn from '@/utils/classnames'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
export type AppIconEmojiSelection = {
type: 'emoji'
icon: string
background: string
}
export type AppIconImageSelection = {
type: 'image'
fileId: string
url: string
}
export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection
type AppIconPickerProps = {
onSelect?: (payload: AppIconSelection) => void
onClose?: () => void
className?: string
}
const AppIconPicker: FC<AppIconPickerProps> = ({
onSelect,
onClose,
className,
}) => {
const { t } = useTranslation()
const tabs = [
{ key: 'emoji', label: t('app.iconPicker.emoji'), icon: <span className="text-lg">🤖</span> },
{ key: 'image', label: t('app.iconPicker.image'), icon: <ImagePlus /> },
]
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
const [emoji, setEmoji] = useState<{ emoji: string; background: string }>()
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
setEmoji({ emoji, background })
}, [setEmoji])
const [uploading, setUploading] = useState<boolean>()
const { handleLocalFileUpload } = useLocalFileUploader({
limit: 3,
disabled: false,
onUpload: (imageFile: ImageFile) => {
if (imageFile.fileId) {
setUploading(false)
onSelect?.({
type: 'image',
fileId: imageFile.fileId,
url: imageFile.url,
})
}
},
})
const [imageCropInfo, setImageCropInfo] = useState<{ tempUrl: string; croppedAreaPixels: Area; fileName: string }>()
const handleImageCropped = async (tempUrl: string, croppedAreaPixels: Area, fileName: string) => {
setImageCropInfo({ tempUrl, croppedAreaPixels, fileName })
}
const handleSelect = async () => {
if (activeTab === 'emoji') {
if (emoji) {
onSelect?.({
type: 'emoji',
icon: emoji.emoji,
background: emoji.background,
})
}
}
else {
if (!imageCropInfo)
return
setUploading(true)
const blob = await getCroppedImg(imageCropInfo.tempUrl, imageCropInfo.croppedAreaPixels)
const file = new File([blob], imageCropInfo.fileName, { type: blob.type })
handleLocalFileUpload(file)
}
}
return <Modal
onClose={() => { }}
isShow
closable={false}
wrapperClassName={className}
className={cn(s.container, '!w-[362px] !p-0')}
>
{!DISABLE_UPLOAD_IMAGE_AS_ICON && <div className="p-2 pb-0 w-full">
<div className='p-1 flex items-center justify-center gap-2 bg-background-body rounded-xl'>
{tabs.map(tab => (
<button
key={tab.key}
className={`
p-2 flex-1 flex justify-center items-center h-8 rounded-xl text-sm shrink-0 font-medium
${activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active shadow-md'}
`}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon} &nbsp; {tab.label}
</button>
))}
</div>
</div>}
<Divider className='m-0' />
<EmojiPickerInner className={activeTab === 'emoji' ? 'block' : 'hidden'} onSelect={handleSelectEmoji} />
<Uploader className={activeTab === 'image' ? 'block' : 'hidden'} onImageCropped={handleImageCropped} />
<Divider className='m-0' />
<div className='w-full flex items-center justify-center p-3 gap-2'>
<Button className='w-full' onClick={() => onClose?.()}>
{t('app.iconPicker.cancel')}
</Button>
<Button variant="primary" className='w-full' disabled={uploading} loading={uploading} onClick={handleSelect}>
{t('app.iconPicker.ok')}
</Button>
</div>
</Modal>
}
export default AppIconPicker

View File

@@ -0,0 +1,12 @@
.container {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 362px;
max-height: 552px;
border: 0.5px solid #EAECF0;
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
border-radius: 12px;
background: #fff;
}

View File

@@ -0,0 +1,98 @@
export const createImage = (url: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', error => reject(error))
image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox
image.src = url
})
export function getRadianAngle(degreeValue: number) {
return (degreeValue * Math.PI) / 180
}
/**
* Returns the new bounding area of a rotated rectangle.
*/
export function rotateSize(width: number, height: number, rotation: number) {
const rotRad = getRadianAngle(rotation)
return {
width:
Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
height:
Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
}
}
/**
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
*/
export default async function getCroppedImg(
imageSrc: string,
pixelCrop: { x: number; y: number; width: number; height: number },
rotation = 0,
flip = { horizontal: false, vertical: false },
): Promise<Blob> {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx)
throw new Error('Could not create a canvas context')
const rotRad = getRadianAngle(rotation)
// calculate bounding box of the rotated image
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
image.width,
image.height,
rotation,
)
// set canvas size to match the bounding box
canvas.width = bBoxWidth
canvas.height = bBoxHeight
// translate canvas context to a central location to allow rotating and flipping around the center
ctx.translate(bBoxWidth / 2, bBoxHeight / 2)
ctx.rotate(rotRad)
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1)
ctx.translate(-image.width / 2, -image.height / 2)
// draw rotated image
ctx.drawImage(image, 0, 0)
const croppedCanvas = document.createElement('canvas')
const croppedCtx = croppedCanvas.getContext('2d')
if (!croppedCtx)
throw new Error('Could not create a canvas context')
// Set the size of the cropped canvas
croppedCanvas.width = pixelCrop.width
croppedCanvas.height = pixelCrop.height
// Draw the cropped image onto the new canvas
croppedCtx.drawImage(
canvas,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((file) => {
if (file)
resolve(file)
else
reject(new Error('Could not create a blob'))
}, 'image/jpeg')
})
}