chore(form): compressed avatar

This commit is contained in:
molvqingtai 2023-11-30 16:50:18 +08:00
parent 8df1b08fe5
commit bac534f5d1
17 changed files with 766 additions and 644 deletions

View file

@ -59,15 +59,15 @@
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"idb-keyval": "^6.2.1",
"lucide-react": "^0.292.0",
"lucide-react": "^0.294.0",
"nanoid": "^5.0.3",
"peerjs": "^1.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-markdown": "^9.0.1",
"react-nice-avatar": "^1.4.1",
"react-use": "^17.4.0",
"react-nice-avatar": "^1.5.0",
"react-use": "^17.4.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remesh": "^4.2.0",
@ -82,9 +82,9 @@
"devDependencies": {
"@commitlint/cli": "^18.4.3",
"@commitlint/config-conventional": "^18.4.3",
"@types/node": "^20.9.3",
"@types/react": "^18.2.38",
"@types/react-dom": "^18.2.16",
"@types/node": "^20.10.0",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"cross-env": "^7.0.3",
@ -108,7 +108,7 @@
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.3.2",
"webext-bridge": "^6.0.1",
"wxt": "^0.10.2"
"wxt": "^0.10.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --fix"

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,6 @@
import { browser } from 'wxt/browser'
import { defineBackground } from 'wxt/client'
export default defineBackground({
// Set manifest options
persistent: true,

View file

@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { Remesh } from 'remesh'
import { RemeshRoot } from 'remesh-react'
import { RemeshLogger } from 'remesh-logger'
import { defineContentScript, createContentScriptUi } from 'wxt/client'
import App from './App'
import { StorageIndexDBImpl } from '@/impl/Storage'
import '@/assets/styles/tailwind.css'

View file

@ -1,6 +1,7 @@
import { type ReactNode, type FC, useState, type MouseEvent, useRef } from 'react'
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
import { useClickAway } from 'react-use'
import { browser } from 'wxt/browser'
import { Button } from '@/components/ui/Button'
import { EVENTS } from '@/constants'

View file

@ -1,41 +1,58 @@
import { type FC, type ChangeEvent } from 'react'
import { Globe2Icon } from 'lucide-react'
import { type ChangeEvent } from 'react'
import { ImagePlusIcon } from 'lucide-react'
import React from 'react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Label } from '@/components/ui/Label'
import { cn, compressImage } from '@/utils'
export interface AvatarSelectProps {
value?: string
className?: string
disabled?: boolean
onload?: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null
onerror?: ((this: FileReader, ev: ProgressEvent) => any) | null
onChange?: (src: string) => void
}
const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
({ onChange, value, onerror, onload, className }, ref) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
({ onChange, value, onerror, onload, className, disabled }, ref) => {
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
// Compress to 10kb
const blob = await compressImage(file, 10 * 1024)
const reader = new FileReader()
reader.onload = (e) => {
onload?.call(reader, e)
const src = e.target?.result as string
onChange?.(src)
console.log(file.size, blob.size)
}
reader.onerror = (e) => onerror?.call(reader, e)
reader.readAsDataURL(file)
reader.readAsDataURL(blob)
}
}
return (
<Label className="contents">
<Avatar className={cn('h-20 w-20 cursor-pointer border-4 border-white', className)}>
<Avatar
tabIndex={disabled ? -1 : 1}
className={cn(
'group h-20 w-20 cursor-pointer border-4 border-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
{
'cursor-not-allowed': disabled,
'opacity-50': disabled
},
className
)}
>
<AvatarImage src={value} alt="avatar" />
<AvatarFallback>
<Globe2Icon size="100%" className="text-gray-400" />
<ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
</AvatarFallback>
</Avatar>
<input ref={ref} hidden type="file" accept="image/*" onChange={handleChange} />
<input ref={ref} hidden disabled={disabled} type="file" accept="image/*" onChange={handleChange} />
</Label>
)
}

View file

@ -1,6 +1,7 @@
import { object, string, type Output, minLength, maxLength, toTrimmed, boolean } from 'valibot'
import { object, string, type Output, minBytes, maxBytes, toTrimmed, boolean, notLength } from 'valibot'
import { useForm } from 'react-hook-form'
import { valibotResolver } from '@hookform/resolvers/valibot'
import AvatarSelect from './AvatarSelect'
import { Button } from '@/components/ui/Button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
@ -10,10 +11,10 @@ import { Switch } from '@/components/ui/Switch'
const formSchema = object({
username: string([
toTrimmed(),
minLength(1, 'Please enter your username.'),
maxLength(8, 'Your username must have 8 characters or more.')
minBytes(1, 'Please enter your username.'),
maxBytes(20, 'Your username cannot exceed 20 bytes.')
]),
avatar: string(),
avatar: string([notLength(0, 'Please select your avatar.')]),
darkMode: boolean()
})
@ -29,21 +30,19 @@ const ProfileForm = () => {
const handleSubmit = (data: Output<typeof formSchema>) => {
console.log(data)
console.log(data.avatar.length * 0.001)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="relative w-96 space-y-8 p-10">
<form onSubmit={form.handleSubmit(handleSubmit)} autoComplete="off" className="relative w-96 space-y-8 p-10">
<FormField
control={form.control}
name="avatar"
render={({ field }) => (
<FormItem>
<FormItem className="absolute left-1/2 top-0 grid -translate-x-1/2 -translate-y-1/2 justify-items-center pb-8">
<FormControl>
<AvatarSelect
className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 shadow-lg"
{...field}
></AvatarSelect>
<AvatarSelect className="shadow-lg" {...field}></AvatarSelect>
</FormControl>
<FormMessage />
</FormItem>
@ -69,15 +68,19 @@ const ProfileForm = () => {
render={({ field }) => (
<FormItem>
<FormLabel>DarkMode</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange}></Switch>
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormDescription>Enable dark mode</FormDescription>
<FormMessage />
</div>
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
<Button className="w-full" type="submit">
Save
</Button>
</form>
</Form>
)

View file

@ -1,4 +1,5 @@
import indexedDbDriver from 'unstorage/drivers/indexedb'
import { webExtensionDriver, createStorage } from 'wxt/storage'
import StorageExtern from '@/domain/externs/Storage'
import { STORAGE_NAME } from '@/constants'

4
src/utils/chunk.ts Normal file
View file

@ -0,0 +1,4 @@
const chunk = <T = any>(array: T[], size: number) =>
Array.from({ length: Math.ceil(array.length / size) }, (_v, i) => array.slice(i * size, i * size + size))
export default chunk

2
src/utils/clamp.ts Normal file
View file

@ -0,0 +1,2 @@
const clamp = (number: number, min: number, max: number) => Math.min(Math.max(number, min), max)
export default clamp

6
src/utils/cn.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
export default cn

View file

@ -0,0 +1,62 @@
const compress = async (
imageBitmap: ImageBitmap,
size: number,
low: number,
high: number,
bestBlob: Blob
): Promise<Blob> => {
// Calculate the middle value of quality
const mid = (low + high) / 2
// Calculate the width and height after scaling
const width = imageBitmap.width * mid
const height = imageBitmap.height * mid
const offscreenCanvas = new OffscreenCanvas(width, height)
const offscreenContext = offscreenCanvas.getContext('2d')!
offscreenContext.drawImage(imageBitmap, 0, 0, width, height)
const outputBlob = await offscreenCanvas.convertToBlob({ type: 'image/jpeg', quality: mid })
// Calculate the current size based on the current quality
const currentSize = outputBlob.size
// If the current size is close to the target size, update the bestResult
if (Math.abs(currentSize - size) < Math.abs(bestBlob.size - size)) {
bestBlob = outputBlob
}
// If the current size is close to the target size or the range of low and high is too small, return the result
if (Math.abs(currentSize - size) < 100 || high - low < 0.01) {
return bestBlob
}
// Adjust the range for recursion based on the current quality and size
if (currentSize > size) {
return await compress(imageBitmap, size, low, mid, bestBlob)
} else {
return await compress(imageBitmap, size, mid, high, bestBlob)
}
}
const compressImage = async (inputBlob: Blob, targetSize: number) => {
// If the original size already meets the target size, return the original Blob
if (inputBlob.size <= targetSize) {
return inputBlob
}
const imageBitmap = await createImageBitmap(inputBlob)
// Initialize the range of quality
const low = 0
const high = 1
// Initialize bestBlob with the original input Blob
const bestBlob = inputBlob
// Call the recursive function
return await compress(imageBitmap, targetSize, low, high, bestBlob)
}
export default compressImage

View file

@ -0,0 +1,5 @@
const createElement = <T extends Element>(template: string) => {
return new Range().createContextualFragment(template).firstElementChild as unknown as T
}
export default createElement

View file

@ -0,0 +1,34 @@
export interface WebSiteInfo {
host: string
hostname: string
href: string
origin: string
title: string
icon: string
description: string
}
const getWebSiteInfo = (): WebSiteInfo => {
return {
host: document.location.host,
hostname: document.location.hostname,
href: document.location.href,
origin: document.location.origin,
title:
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
document.querySelector('meta[rel="og:site_name i"]')?.getAttribute('content') ??
document.title,
icon:
document.querySelector('meta[property="og:image" i]')?.getAttribute('href') ??
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
`${document.location.origin}/favicon.ico`,
description:
document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ??
document.querySelector('meta[name="description" i]')?.getAttribute('content') ??
''
}
}
export default getWebSiteInfo

View file

@ -1,49 +1,7 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export interface WebSiteInfo {
host: string
hostname: string
href: string
origin: string
title: string
icon: string
description: string
}
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs))
}
export const createElement = <T extends Element>(template: string) => {
return new Range().createContextualFragment(template).firstElementChild as unknown as T
}
export const chunk = <T = any>(array: T[], size: number) =>
Array.from({ length: Math.ceil(array.length / size) }, (_v, i) => array.slice(i * size, i * size + size))
export const clamp = (number: number, min: number, max: number) => Math.min(Math.max(number, min), max)
export const isInRange = (number: number, min: number, max: number) => number >= min && number <= max
export const getWebSiteInfo = (): WebSiteInfo => {
return {
host: document.location.host,
hostname: document.location.hostname,
href: document.location.href,
origin: document.location.origin,
title:
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
document.querySelector('meta[rel="og:site_name i"]')?.getAttribute('content') ??
document.title,
icon:
document.querySelector('meta[property="og:image" i]')?.getAttribute('href') ??
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
`${document.location.origin}/favicon.ico`,
description:
document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ??
document.querySelector('meta[name="description" i]')?.getAttribute('content') ??
''
}
}
export { default as cn } from './cn'
export { default as isInRange } from './isInRange'
export { default as clamp } from './clamp'
export { default as createElement } from './createElement'
export { default as getWebSiteInfo } from './getWebSiteInfo'
export { default as chunk } from './chunk'
export { default as compressImage } from './compressImage'

3
src/utils/isInRange.ts Normal file
View file

@ -0,0 +1,3 @@
const isInRange = (number: number, min: number, max: number) => number >= min && number <= max
export default isInRange

View file

@ -7,6 +7,7 @@ const isDev = process.env.NODE_ENV === 'development'
export default defineConfig({
srcDir: path.resolve('src'),
imports: false,
entrypointsDir: path.resolve('src', 'app'),
runner: {
startUrls: ['https://www.example.com/']