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

View file

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

View file

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

View file

@ -1,41 +1,58 @@
import { type FC, type ChangeEvent } from 'react' import { type ChangeEvent } from 'react'
import { Globe2Icon } from 'lucide-react' import { ImagePlusIcon } from 'lucide-react'
import React from 'react' import React from 'react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { cn, compressImage } from '@/utils'
export interface AvatarSelectProps { export interface AvatarSelectProps {
value?: string value?: string
className?: string className?: string
disabled?: boolean
onload?: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null onload?: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null
onerror?: ((this: FileReader, ev: ProgressEvent) => any) | null onerror?: ((this: FileReader, ev: ProgressEvent) => any) | null
onChange?: (src: string) => void onChange?: (src: string) => void
} }
const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>( const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
({ onChange, value, onerror, onload, className }, ref) => { ({ onChange, value, onerror, onload, className, disabled }, ref) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (file) { if (file) {
// Compress to 10kb
const blob = await compressImage(file, 10 * 1024)
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
onload?.call(reader, e) onload?.call(reader, e)
const src = e.target?.result as string const src = e.target?.result as string
onChange?.(src) onChange?.(src)
console.log(file.size, blob.size)
} }
reader.onerror = (e) => onerror?.call(reader, e) reader.onerror = (e) => onerror?.call(reader, e)
reader.readAsDataURL(file) reader.readAsDataURL(blob)
} }
} }
return ( return (
<Label className="contents"> <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" /> <AvatarImage src={value} alt="avatar" />
<AvatarFallback> <AvatarFallback>
<Globe2Icon size="100%" className="text-gray-400" /> <ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<input ref={ref} hidden type="file" accept="image/*" onChange={handleChange} /> <input ref={ref} hidden disabled={disabled} type="file" accept="image/*" onChange={handleChange} />
</Label> </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 { useForm } from 'react-hook-form'
import { valibotResolver } from '@hookform/resolvers/valibot' import { valibotResolver } from '@hookform/resolvers/valibot'
import AvatarSelect from './AvatarSelect' import AvatarSelect from './AvatarSelect'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form' 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({ const formSchema = object({
username: string([ username: string([
toTrimmed(), toTrimmed(),
minLength(1, 'Please enter your username.'), minBytes(1, 'Please enter your username.'),
maxLength(8, 'Your username must have 8 characters or more.') maxBytes(20, 'Your username cannot exceed 20 bytes.')
]), ]),
avatar: string(), avatar: string([notLength(0, 'Please select your avatar.')]),
darkMode: boolean() darkMode: boolean()
}) })
@ -29,21 +30,19 @@ const ProfileForm = () => {
const handleSubmit = (data: Output<typeof formSchema>) => { const handleSubmit = (data: Output<typeof formSchema>) => {
console.log(data) console.log(data)
console.log(data.avatar.length * 0.001)
} }
return ( return (
<Form {...form}> <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 <FormField
control={form.control} control={form.control}
name="avatar" name="avatar"
render={({ field }) => ( 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> <FormControl>
<AvatarSelect <AvatarSelect className="shadow-lg" {...field}></AvatarSelect>
className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 shadow-lg"
{...field}
></AvatarSelect>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -69,15 +68,19 @@ const ProfileForm = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>DarkMode</FormLabel> <FormLabel>DarkMode</FormLabel>
<FormControl> <div className="flex items-center gap-x-2">
<Switch checked={field.value} onCheckedChange={field.onChange}></Switch> <FormControl>
</FormControl> <Switch checked={field.value} onCheckedChange={field.onChange}></Switch>
<FormDescription>This is your public display name.</FormDescription> </FormControl>
<FormMessage /> <FormDescription>Enable dark mode</FormDescription>
<FormMessage />
</div>
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit">Submit</Button> <Button className="w-full" type="submit">
Save
</Button>
</form> </form>
</Form> </Form>
) )

View file

@ -1,4 +1,5 @@
import indexedDbDriver from 'unstorage/drivers/indexedb' import indexedDbDriver from 'unstorage/drivers/indexedb'
import { webExtensionDriver, createStorage } from 'wxt/storage'
import StorageExtern from '@/domain/externs/Storage' import StorageExtern from '@/domain/externs/Storage'
import { STORAGE_NAME } from '@/constants' 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' export { default as cn } from './cn'
import { twMerge } from 'tailwind-merge' export { default as isInRange } from './isInRange'
export { default as clamp } from './clamp'
export interface WebSiteInfo { export { default as createElement } from './createElement'
host: string export { default as getWebSiteInfo } from './getWebSiteInfo'
hostname: string export { default as chunk } from './chunk'
href: string export { default as compressImage } from './compressImage'
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') ??
''
}
}

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({ export default defineConfig({
srcDir: path.resolve('src'), srcDir: path.resolve('src'),
imports: false,
entrypointsDir: path.resolve('src', 'app'), entrypointsDir: path.resolve('src', 'app'),
runner: { runner: {
startUrls: ['https://www.example.com/'] startUrls: ['https://www.example.com/']