接到了一个项目的需求,要求在网页上增加头像选择时可以进行裁剪,并且效果要和手机app那种流畅度保持一致,在PC上还需要加一个range的滚动条来控制zoom大小,最后,在提交的末期,还增加了一个首页弹窗提示用户改头像的功能,总之这个票的制作周期非常的漫长,漫长。我们这次的文章专门挑裁剪框与自定义滚动条来讲:

PC设计样图

SP设计样图

一、React-easy-cropper in Nextjs

React-easy-cropper比起cropperjs的优点就在于简单,轻量,他获取到的是坐标值,我们可以自己使用cavans画布来绘图,不需要每次进行大量的图片计算,cropper的change则会次次计算出base64的值,在此基础之上我们或许需要好好思考一下如何节流。

React-easy-crop
Demo

在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的成本显得比较大了。
调查结果

所以干脆一不做二不休我们自己做一个吧!

  1. handleMouseDown 函数中,通过 addEventListener 方法在全局范围内监听鼠标移动和鼠标松开事件。
  2. 当鼠标按下时,会触发 handleMouseDown 函数,并向全局添加鼠标移动事件监听器 handleMouseMove 和鼠标松开事件监听器 handleMouseUp
  3. handleMouseMove 函数中,计算鼠标相对于滑块的偏移量,并将其转换为百分比。
  4. 根据百分比计算出拖动后的值,并通过调用 onChange 回调函数将新的值传递给父组件。
  5. 同时,通过更新 thumbRefstyle.left 属性来调整滑块的位置,使其随着拖动而移动。
  6. 当鼠标松开时,会触发 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值,可以考虑使用useRecoilStateuseRecoilCallback等其他Recoil提供的方法,以避免此报错并实现您的需求。异步可以使用useRecoilRequest方法。

最后修改:2024 年 03 月 21 日
收款不要了,给孩子补充点点赞数吧