图片标注框选组件

实际效果

主要思路

  • 从img标签获取图片原始尺寸 获取图片容器尺寸
  • 计算图片实际展示的宽高 将基于原始尺寸的坐标转化为基于展示尺寸的 并设置相应的拖拽区
  • 选择框框使用motion.div构建 用x/y/width/height四个motion value描述
  • 选择框框整体拖拽使用motion自带功能即可 但四边、四角的拖拽逻辑要手写
  • 选择框按照左上角的位置进行排序 设置zindex 越靠上越靠左z越小 尽可能避免某个拖拽框被完全遮盖

代码结构

实际代码

common.ts

ts 复制代码
import { match } from 'ts-pattern'

/** 选择框 */
export type SelectBox = {
  key: string | number
  /** 左上角坐标和右下角坐标 */
  position: [x1: number, y1: number, x2: number, y2: number]
  style?: {
    fill?: string
    border?: string
    borderWidth?: number
  }
}

/** 区域或图片的大小 */
export type Size = {
  width: number
  height: number
}

/** 获取图片的原始尺寸 */
export async function getImageNaturalSize(src: string) {
  const img = new Image()
  img.src = src
  const { promise, resolve, reject } = Promise.withResolvers<Size>()
  img.addEventListener('load', () => {
    resolve({ width: img.naturalWidth, height: img.naturalHeight })
  })
  img.addEventListener('error', reject)
  return promise
}

/** 计算图片的展示尺寸 */
export function getDisplaySize(naturalSize: Size, containerSize: Size) {
  const scaleX = containerSize.width / naturalSize.width
  const scaleY = containerSize.height / naturalSize.height
  const scale = Math.min(scaleX, scaleY)
  return {
    width: naturalSize.width * scale,
    height: naturalSize.height * scale,
  }
}

/** 计算选择框的z-index 使所有选择框都可以点击 */
export function getBoxZIndexs(boxs: SelectBox[]) {
  const keyMap = new Map<SelectBox['key'], number>()
  // 矩形靠上、靠左的 z较低
  const sorted = [...boxs].sort((A, B) => {
    const {
      position: [Ax1, Ay1],
    } = A
    const {
      position: [Bx1, By1],
    } = B
    return Ay1 !== By1 ? Ay1 - By1 : Ax1 - Bx1
  })
  sorted.forEach((item, i) => {
    keyMap.set(item.key, i)
  })
  return boxs.map((item) => keyMap.get(item.key))
}

/**
 * 将基于原始尺寸的坐标转化为基于展示尺寸的坐标 或者反过来/
 * 转化后的坐标不小于0 不大于目标尺寸的最大值
 */
export function convertPosition(
  position: SelectBox['position'],
  naturalSize: Size,
  displaySize: Size,
  to: 'natural' | 'display',
) {
  const [originSize, resultSize] = match(to)
    .with('display', () => [naturalSize, displaySize])
    .with('natural', () => [displaySize, naturalSize])
    .exhaustive()
  const scaleX = resultSize.width / originSize.width
  const scaleY = resultSize.height / originSize.height
  const maxX = resultSize.width
  const maxY = resultSize.height
  const [x1, y1, x2, y2] = position
  return [
    Math.max(0, Math.min(x1 * scaleX, maxX)),
    Math.max(0, Math.min(y1 * scaleY, maxY)),
    Math.max(0, Math.min(x2 * scaleX, maxX)),
    Math.max(0, Math.min(y2 * scaleY, maxY)),
  ] as SelectBox['position']
}

/** 获取符合左上右下顺序的position */
export function getLegalPosition(position: SelectBox['position']) {
  const [_x1, _y1, _x2, _y2] = position
  const [x1, x2] = _x1 <= _x2 ? [_x1, _x2] : [_x2, _x1]
  const [y1, y2] = _y1 <= _y2 ? [_y1, _y2] : [_y2, _y1]
  return [x1, y1, x2, y2] as SelectBox['position']
}

/** 拖拽方向类型 */
export type DragDirection =
  | 'top'
  | 'top-left'
  | 'top-right'
  | 'left'
  | 'right'
  | 'bottom'
  | 'bottom-left'
  | 'bottom-right'

export const DirectionCursorMap: Record<DragDirection, string> = {
  top: 'ns-resize',
  bottom: 'ns-resize',
  left: 'ew-resize',
  right: 'ew-resize',
  'top-left': 'nwse-resize',
  'bottom-right': 'nwse-resize',
  'top-right': 'nesw-resize',
  'bottom-left': 'nesw-resize',
}

/** 根据拖拽方向获取鼠标指针样式 */
export function getCursorStyle(dir: DragDirection) {
  return DirectionCursorMap[dir]
}

index.tsx

tsx 复制代码
import {
  useRef,
  forwardRef,
  useImperativeHandle,
  useState,
  useMemo,
} from 'react'
import { Skeleton } from 'antd'
import { useMemoizedFn, useSize } from 'ahooks'
import useSWR from 'swr'
import { match, P } from 'ts-pattern'
import { cn } from '@/utils'
import { SelectBoxComponent } from './SelectBoxComponent'
import {
  type SelectBox,
  convertPosition,
  getImageNaturalSize,
  getDisplaySize,
  getBoxZIndexs,
} from './common'

export { type SelectBox } from './common'

export type ImageSelectBoxProps = Style & {
  src: string
  boxs: SelectBox[]
  onPositionChange: (key: SelectBox['key'], position: SelectBox['position']) => void
  onCreateBox?: (position: SelectBox['position']) => void
  onClick?: (boxKey: SelectBox['key'] | undefined, e: React.MouseEvent) => void
}

export type ImageSelectBoxInstance = {
  /** 调用此函数后 在拖拽区点击 便可创建一个新的区域并开启其拖拽 */
  createBox: () => void
}

export const ImageSelectBox = forwardRef<ImageSelectBoxInstance, ImageSelectBoxProps>(
  (props, ref) => {
    const { src, boxs, onPositionChange, onCreateBox, onClick, className, style } = props

    // 图片和拖拽区的尺寸
    const { data: naturalSize, isValidating, error } = useSWR(src, getImageNaturalSize)
    const containerRef = useRef<HTMLDivElement>(null)
    const containerSize = useSize(containerRef)
    // 计算展示尺寸
    const displaySize = useMemo(() => {
      if (!naturalSize || !containerSize) return undefined
      const size = getDisplaySize(naturalSize, containerSize)
      return size
    }, [containerSize, naturalSize])
    const dragRange = useRef<HTMLDivElement>(null)

    // 为选择框设置zindex 避免重叠遮挡
    const zIndexList = useMemo(() => {
      const zList = getBoxZIndexs(boxs)
      return zList
    }, [boxs])
    
    // 用于点击选择框事件 如果指针移动 则不触发此事件
    const pointerMoved = useRef(false)

    // 创建选择框
    const isCreating = useRef(false)
    const [creatingInitPosition, setInitPosition] = useState<SelectBox['position']>()
    const createBox = useMemoizedFn(() => {
      isCreating.current = true
    })
    useImperativeHandle(
      ref,
      () => ({
        createBox,
      }),
      [createBox],
    )
    return (
      <div
        className={cn(
          'relative flex h-full w-full items-center justify-center overflow-hidden',
          className,
        )}
        style={style}
        onClick={(e) => {
          if (e.target === e.currentTarget) {
            onClick?.(undefined, e)
          }
        }}
        ref={containerRef}
      >
        {match({ isValidating, error: error as Error | null, naturalSize, displaySize })
          .with({ isValidating: false, error: P.nonNullable }, () => '图片加载失败')
          .with(
            { isValidating: false, naturalSize: P.nonNullable, displaySize: P.nonNullable },
            ({ naturalSize, displaySize }) => (
              <>
                <img src={src} className='h-full w-full object-contain' />
                <div
                  className='absolute top-1/2 left-1/2 z-10 -translate-x-1/2 -translate-y-1/2'
                  style={displaySize}
                  ref={dragRange}
                  onPointerDownCapture={(e) => {
                    pointerMoved.current = false
                    if (!isCreating.current) return
                    if (!dragRange.current) return
                    e.stopPropagation()
                    const { clientX, clientY } = e
                    const { x = 0, y = 0 } = dragRange.current.getBoundingClientRect()
                    const x1 = clientX - x
                    const y1 = clientY - y
                    setInitPosition([x1, y1, x1, y1])
                  }}
                  onPointerMoveCapture={()=>{
                    pointerMoved.current = true
                  }}
                >
                  {boxs.map((b, i) => (
                    <SelectBoxComponent
                      key={b.key}
                      position={convertPosition(b.position, naturalSize, displaySize, 'display')}
                      dragRange={dragRange}
                      onPositionChange={(val) =>
                        onPositionChange?.(
                          b.key,
                          convertPosition(val, naturalSize, displaySize, 'natural'),
                        )
                      }
                      onClick={(e) => {
                        // 如果鼠标移动了 不触发点击
                        if (pointerMoved.current) return
                        onClick?.(b.key, e)
                      }}
                      style={{ zIndex: zIndexList[i] }}
                    />
                  ))}
                  {creatingInitPosition ? (
                    <SelectBoxComponent
                      position={creatingInitPosition}
                      defaultDragDirection='bottom-right'
                      dragRange={dragRange}
                      onPositionChange={(val) => {
                        onCreateBox?.(convertPosition(val, naturalSize, displaySize, 'natural'))
                        setInitPosition(undefined)
                      }}
                      style={{ zIndex: 9999 }}
                    />
                  ) : null}
                </div>
              </>
            ),
          )
          .otherwise(() => (
            <Skeleton active className='p-4' />
          ))}
      </div>
    )
  },
)

SelectBoxComponent/index.tsx

这个文件包含了拖拽逻辑 包括溢出和翻转等

tsx 复制代码
import React, { useLayoutEffect, FC } from 'react'
import { useMemoizedFn, useMount } from 'ahooks'
import { motion, useMotionValue } from 'motion/react'
import { cn } from '@/utils'
import {
  type SelectBox,
  getLegalPosition,
  getCursorStyle,
  DirectionCursorMap,
  DragDirection,
} from '../common'
import styles from './styles.module.css'

export type SelectBoxComponentProps = Style & {
  position: SelectBox['position']
  dragRange: { current: HTMLElement | null }
  onPositionChange?: (val: SelectBox['position']) => void
  onClick?: (e: React.MouseEvent) => void
  boxStyle?: SelectBox['style']
  /** 传入此项 则选择框初始可拖拽 */
  defaultDragDirection?: DragDirection
}

export const SelectBoxComponent: FC<SelectBoxComponentProps> = (props) => {
  const {
    position,
    dragRange,
    boxStyle,
    onPositionChange,
    onClick,
    defaultDragDirection,
    className,
    style,
  } = props
  const { fill = '#ff000010', border = '#ff0000', borderWidth = 2 } = boxStyle ?? {}

  const x = useMotionValue(0)
  const y = useMotionValue(0)
  const width = useMotionValue(0)
  const height = useMotionValue(0)
  useLayoutEffect(() => {
    const [x1, y1, x2, y2] = getLegalPosition(position)
    x.set(x1)
    y.set(y1)
    width.set(x2 - x1)
    height.set(y2 - y1)
  }, [height, position, width, x, y])
  const onDragEnd = useMemoizedFn(() => {
    const x1 = x.get()
    const y1 = y.get()
    const x2 = x1 + width.get()
    const y2 = y1 + height.get()
    onPositionChange?.([x1, y1, x2, y2])
  })

  /**
   * 拖拽区应当跟随鼠标移动 因此可以根据鼠标位置设置矩形四点的位置
   */
  const startHandlerDrag = useMemoizedFn((dir: DragDirection) => {
    document.body.classList.add(styles.dragging)
    document.body.style.setProperty('--cursor-style', getCursorStyle(dir))
    document.addEventListener('pointermove', onHandlerDrag)
    document.addEventListener('pointerup', stopHandlerDrag)

    function stopHandlerDrag() {
      document.body.classList.remove(styles.dragging)
      document.body.style.removeProperty('--cursor-style')
      document.removeEventListener('pointermove', onHandlerDrag)
      document.removeEventListener('pointerup', stopHandlerDrag)
      onDragEnd()
    }

    let currentDirection = dir
    function onHandlerDrag(e: PointerEvent) {
      if (!dragRange.current) return
      const { clientX, clientY } = e
      const {
        x: rangeX,
        y: rangeY,
        width: maxWidth,
        height: maxHeight,
      } = dragRange.current.getBoundingClientRect()

      // 在允许水平拖拽时计算x/width
      // 注意矩形翻转的问题
      if (currentDirection.includes('left') || currentDirection.includes('right')) {
        const currentX1 = x.get()
        const currentX2 = currentX1 + width.get()
        const isLeft = currentDirection.includes('left')
        let [newCurrentX1, newCurrentX2] = isLeft
          ? [clientX - rangeX, currentX2]
          : [currentX1, clientX - rangeX]
        // newCurrentX1 > newCurrentX2 说明之前的左上点变为了右上点 矩形翻转了
        // 翻转后 拖拽防线同样需要更改
        if (newCurrentX1 > newCurrentX2) {
          ;[newCurrentX1, newCurrentX2] = [newCurrentX2, newCurrentX1]
          currentDirection = (
            isLeft
              ? currentDirection.replace('left', 'right')
              : currentDirection.replace('right', 'left')
          ) as DragDirection
        }
        // 矩形大小溢出
        if (newCurrentX1 < 0) {
          newCurrentX1 = 0
        }
        if (newCurrentX2 > maxWidth) {
          newCurrentX2 = maxWidth
        }
        x.set(newCurrentX1)
        width.set(newCurrentX2 - newCurrentX1)
      }
      // 在允许竖直拖拽时计算y/height 逻辑类似
      if (currentDirection.includes('top') || currentDirection.includes('bottom')) {
        const currentY1 = y.get()
        const currentY2 = currentY1 + height.get()
        const isTop = currentDirection.includes('top')
        let [newCurrentY1, newCurrentY2] = isTop
          ? [clientY - rangeY, currentY2]
          : [currentY1, clientY - rangeY]
        if (newCurrentY1 > newCurrentY2) {
          ;[newCurrentY1, newCurrentY2] = [newCurrentY2, newCurrentY1]
          currentDirection = (
            isTop
              ? currentDirection.replace('top', 'bottom')
              : currentDirection.replace('bottom', 'top')
          ) as DragDirection
        }
        if (newCurrentY1 < 0) {
          newCurrentY1 = 0
        }
        if (newCurrentY2 > maxHeight) {
          newCurrentY2 = maxHeight
        }
        y.set(newCurrentY1)
        height.set(newCurrentY2 - newCurrentY1)
      }
    }
  })

  useMount(() => {
    if (defaultDragDirection) {
      startHandlerDrag(defaultDragDirection)
    }
  })
  return (
    <motion.div
      className={cn('absolute top-0 left-0', styles.selectBox, className)}
      style={{
        // @ts-expect-error 为拖拽手柄提供css变量
        '--handler-width': `${borderWidth}px`,
        '--handler-background-color': border,
        backgroundColor: fill,
        x,
        y,
        width,
        height,
        ...style,
      }}
      drag
      dragElastic={0}
      dragMomentum={false}
      dragConstraints={dragRange}
      onDragEnd={onDragEnd}
      onClick={onClick}
    >
      {Object.keys(DirectionCursorMap).map((dir) => (
        <div
          key={dir}
          className={cn(styles.handler, styles[dir])}
          onPointerDownCapture={(e) => {
            e.stopPropagation()
            startHandlerDrag(dir as DragDirection)
          }}
        ></div>
      ))}
    </motion.div>
  )
}

SelectBoxComponent/styles.module.css

css 复制代码
body.dragging {

  &,
  & * {
    cursor: var(--cursor-style) !important;
  }
}

.selectBox {
  .handler {
    position: absolute;
    width: var(--handler-width);
    height: var(--handler-width);
    background-color: var(--handler-background-color);

    /* 角落手柄 - 正方形 */
    &.top-left {
      top: 0;
      left: 0;
      cursor: nwse-resize;
    }

    &.top-right {
      top: 0;
      right: 0;
      cursor: nesw-resize;
    }

    &.bottom-left {
      bottom: 0;
      left: 0;
      cursor: nesw-resize;
    }

    &.bottom-right {
      bottom: 0;
      right: 0;
      cursor: nwse-resize;
    }

    /* 边缘手柄 - 矩形 */
    &.top {
      top: 0;
      left: var(--handler-width);
      right: var(--handler-width);
      width: auto;
      cursor: ns-resize;
    }

    &.bottom {
      bottom: 0;
      left: var(--handler-width);
      right: var(--handler-width);
      width: auto;
      cursor: ns-resize;
    }

    &.left {
      left: 0;
      top: var(--handler-width);
      bottom: var(--handler-width);
      height: auto;
      cursor: ew-resize;
    }

    &.right {
      right: 0;
      top: var(--handler-width);
      bottom: var(--handler-width);
      height: auto;
      cursor: ew-resize;
    }
  }
}
相关推荐
weipt2 小时前
关于vue项目中cesium的地图显示问题
前端·javascript·vue.js·cesium·卫星影像·地形
懒大王、2 小时前
Vue3 + OpenSeadragon 实现 MRXS 病理切片图像预览
前端·javascript·vue.js·openseadragon·mrxs
SoaringHeart2 小时前
Flutter最佳实践:路由弹窗终极版NSlidePopupRoute
前端·flutter
子玖2 小时前
antd6的table排序功能
前端·react.js
程序员小李白2 小时前
动画2详细解析
前端·css·css3
eason_fan3 小时前
Rspack核心解析:Rust重写Webpack的性能革命与本质
前端·前端工程化
诗意地回家3 小时前
淘宝小游戏反编译
开发语言·前端·javascript
徐同保3 小时前
react两个组件中间加一个可以拖动跳转左右大小的功能
前端·javascript·react.js
爱迪斯通3 小时前
MANUS:用于视觉、语言、行动模型创建的高保真第一人称数据采集设备
前端