接到了一个项目的需求,要求在网页上增加头像选择时可以进行裁剪,并且效果要和手机app那种流畅度保持一致,在PC上还需要加一个range的滚动条来控制zoom大小,最后,在提交的末期,还增加了一个首页弹窗提示用户改头像的功能,总之这个票的制作周期非常的漫长,漫长。我们这次的文章专门挑裁剪框与自定义滚动条来讲:
一、React-easy-cropper in Nextjs
React-easy-cropper比起cropperjs的优点就在于简单,轻量,他获取到的是坐标值,我们可以自己使用cavans画布来绘图,不需要每次进行大量的图片计算,cropper的change则会次次计算出base64的值,在此基础之上我们或许需要好好思考一下如何节流。
在Nextjs中,我们使用tailwindcss来处理样式表,因为项目的隐私性,具体的代码无法一次性贴出,我这边给出几个需要注意的点:
这是一个SP和PC共通的组件,所以我们需要传递一下props:
interface ImageCropperProps {
src: string
closeCropper: () => void
doneCropper: (cropperDataUrl: string) => void
isPC?: boolean
}
src就是裁剪的图像的base64,如果你需要传入Https图像链接,下面有专门的链接转base64的方法??,需要注意,此方法需要服务器做跨域处理,src渲染image标签不需要同源策略,但是处理图片需要
export const urlToBase64 = (url: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const context = canvas.getContext('2d');
if (context) {
context.drawImage(img, 0, 0);
const base64Data = canvas.toDataURL('image/png');
resolve(base64Data);
} else {
reject(new Error('Error converting image to base64.'));
}
};
img.onerror = () => {
reject(new Error('Failed to load the image.'));
};
img.src = url;
});
}
下面是基础的获取file文件的base64的方法
export const readFile = (file: Blob): Promise<string> => {
return new Promise((resolve) => {
const reader = new FileReader()
reader.addEventListener(
'load',
() => resolve(reader.result as string),
false
)
reader.readAsDataURL(file)
})
}
下面是基本的图片压缩方法(因为图片加载像素点图片太大的话需要花费时间处理,会让整个画面不够流畅)
export const resizeImage = (
url: string,
resizingFactor = 0.5
): Promise<string> => {
return new Promise((resolve) => {
const inputImage = new Image()
inputImage.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const originalWidth = inputImage.width
const originalHeight = inputImage.height
const canvasWidth = originalWidth * resizingFactor
const canvasHeight = originalHeight * resizingFactor
canvas.width = canvasWidth
canvas.height = canvasHeight
ctx &&
ctx.drawImage(
inputImage,
0,
0,
originalWidth * resizingFactor,
originalHeight * resizingFactor
)
resolve(canvas.toDataURL())
}
inputImage.src = url
})
}
接下来引入React-easy-crop组件,并且设置一个Point类型的state值来记录我们裁剪框的坐标。再设置一个Number类型state值来记录Zoom值以及方便后面操作我们的第二个自定义组件RangeSlider,一个Area类型的state记录区域指定裁剪区域。
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [areaPixels, setAreaPixels] = useState<Area>()
useEffect(() => {
setZoom(1)
setCrop({ x: 0, y: 0 })
}, [src])
这是组件的使用:
<Cropper
image={src}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
showGrid={false}
/>
我们不应该在用户停止操作的时候就去画图base64的图片字符,因为这样会造成无所谓的性能浪费,所以在react-easy-crop的参数,onCropComplete中,我们只需要设置Area的值:
const onCropComplete = useCallback(
(croppedArea: Area, croppedAreaPixels: Area) => {
setAreaPixels(croppedAreaPixels)
},
[]
)
在组件完成裁剪的那一次性的事件中,我们再去画图(我这边是外部的一个按钮完成的事件)
<Button
className="w-full"
color="default"
onClick={handleDoneCropper}
>
DONE
</Button>
const handleDoneCropper = useCallback(() => {
const generateCroppedImage = () => {
const canvas = document.createElement('canvas')
const image = document.createElement('img')
if (!areaPixels) return
image.src = src
image.onload = () => {
const scaleX = image.naturalWidth / image.width
const scaleY = image.naturalHeight / image.height
canvas.width = areaPixels.width
canvas.height = areaPixels.height
const ctx = canvas.getContext('2d')
ctx?.drawImage(
image,
areaPixels.x * scaleX,
areaPixels.y * scaleY,
areaPixels.width * scaleX,
areaPixels.height * scaleY,
0,
0,
areaPixels.width,
areaPixels.height
)
const croppedImage = canvas.toDataURL('image/jpeg')
doneCropper(croppedImage)
}
}
generateCroppedImage()
}, [areaPixels, doneCropper, src])
二、RangeSlider自定义组件
在PC的设计稿中,那个蓝色的滚动条就是Range的拖动条,本来应该使用Input的type="range"可以直接设定拖动的样式,但是因为伪类嵌套过深,加上我们是tailwindcss这个工具来实现样式,所以改动input的成本显得比较大了。
调查结果
所以干脆一不做二不休我们自己做一个吧!
- 在
handleMouseDown
函数中,通过addEventListener
方法在全局范围内监听鼠标移动和鼠标松开事件。 - 当鼠标按下时,会触发
handleMouseDown
函数,并向全局添加鼠标移动事件监听器handleMouseMove
和鼠标松开事件监听器handleMouseUp
。 - 在
handleMouseMove
函数中,计算鼠标相对于滑块的偏移量,并将其转换为百分比。 - 根据百分比计算出拖动后的值,并通过调用
onChange
回调函数将新的值传递给父组件。 - 同时,通过更新
thumbRef
的style.left
属性来调整滑块的位置,使其随着拖动而移动。 - 当鼠标松开时,会触发
handleMouseUp
函数,并从全局中移除鼠标移动事件监听器handleMouseMove
和鼠标松开事件监听器handleMouseUp
。
import React, { useRef, useEffect, useCallback } from 'react'
interface RangeSliderProps {
value: number
onChange: (value: number) => void
minValue: number
maxValue: number
}
const RangeSlider: React.FC<RangeSliderProps> = ({
value,
onChange,
minValue,
maxValue,
}) => {
const sliderRef = useRef<HTMLDivElement>(null)
const thumbRef = useRef<HTMLDivElement>(null)
const handleMouseMove = useCallback(
(event: MouseEvent) => {
if (sliderRef.current && thumbRef.current) {
// 获取滑块的宽度
const sliderWidth = sliderRef.current.offsetWidth
// 计算滑块相对于视口边缘的偏移量,为了计算鼠标相对于滑块的位置
const sliderLeft = sliderRef.current.getBoundingClientRect().left
// 计算鼠标在滑块的水平偏移量
const offsetX = event.clientX - sliderLeft
// 表示拖动位置的百分比
const percent = (offsetX / sliderWidth) * 100
// 确保拖动位置不超过滑块的范围
const clampedPercent = Math.max(Math.min(percent, 100), 0)
// 根据限制后的百分比和给定的最小值和最大值,计算出新的数值。
const clampedValue =
(maxValue - minValue) * (clampedPercent / 100) + minValue
// 传递给父组件,更新状态
onChange(clampedValue)
// 更新滑块的位置
thumbRef.current.style.left = `calc(${clampedPercent}% - 0.5rem)`
}
},
[maxValue, minValue, onChange]
)
const handleMouseUp = useCallback(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}, [handleMouseMove])
const handleMouseDown = useCallback(() => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [handleMouseMove, handleMouseUp])
useEffect(() => {
if (thumbRef.current) {
thumbRef.current.addEventListener('mousedown', handleMouseDown)
}
return () => {
if (thumbRef.current) {
thumbRef.current.removeEventListener('mousedown', handleMouseDown)
}
}
}, [handleMouseDown])
useEffect(() => {
const percent = ((value - minValue) / (maxValue - minValue)) * 100
if (thumbRef.current) {
thumbRef.current.style.left = `calc(${percent}% - 0.5rem)`
}
}, [value, minValue, maxValue])
return (
<div
className="relative w-full h-2 bg-blue-gray-01 rounded-full cursor-pointer"
onMouseDown={handleMouseDown}
ref={sliderRef}
>
<div
className="absolute top-0 h-full bg-primary rounded-full"
style={{
width: `${((value - minValue) / (maxValue - minValue)) * 100}%`,
left: 0,
}}
/>
<div
className="absolute top-[4px] w-4 h-4 bg-primary rounded-full shadow-md -translate-y-1/2"
style={{
left: `calc(${
((value - minValue) / (maxValue - minValue)) * 100
}% - 0.5rem)`,
}}
ref={thumbRef}
/>
</div>
)
}
export default RangeSlider
结合注释看,这样,一个简单的滑块组件就做好了
三、useRecoilValue_TRANSITION_SUPPORT_UNSTABLE
我使用了两次及以上的这个方法之后jest之类的工具就报错了。调查了一下:??
useRecoilValue_TRANSITION_SUPPORT_UNSTABLE
是Recoil库中一个过渡阶段的方法,用于在组件中获取Recoil状态值。它类似于useRecoilValue
方法,但存在于过渡版本中,可能不稳定且在正式发布时可能发生变化。
使用useRecoilValue_TRANSITION_SUPPORT_UNSTABLE
方法两次会导致报错是因为在使用Recoil的过渡版本(transitional version)时,该方法被设计为只能在一个组件中使用一次。
Recoil的过渡版本是指尚未正式发布的功能,可能存在变化或不稳定的情况。因此,在同一个hooks函数中多次使用该方法可能会导致意外的行为或错误。
如果需要在同一个组件中使用多个Recoil值,可以考虑使用useRecoilState
或useRecoilCallback
等其他Recoil提供的方法,以避免此报错并实现您的需求。异步可以使用useRecoilRequest
方法。
1 条评论
博主太厉害了!