接到了一个项目的需求,要求在网页上增加头像选择时可以进行裁剪,并且效果要和手机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. 同时,通过更新 thumbRef 的 style.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值,可以考虑使用useRecoilState或useRecoilCallback等其他Recoil提供的方法,以避免此报错并实现您的需求。异步可以使用useRecoilRequest方法。

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