Shadcn Hooks

useTextSelection

A hook to get the text selection and its bounding rect from an element

Loading...

Installation

npx shadcn@latest add @shadcnhooks/use-text-selection
pnpm dlx shadcn@latest add @shadcnhooks/use-text-selection
yarn dlx shadcn@latest add @shadcnhooks/use-text-selection
bun x shadcn@latest add @shadcnhooks/use-text-selection

Copy and paste the following code into your project.

create-effect-with-target.ts
import { isBrowser, isEqual, isFunction } from 'es-toolkit'
import { useRef } from 'react'
import { useUnmount } from '@/registry/hooks/use-unmount'
import type {
  DependencyList,
  EffectCallback,
  RefObject,
  useEffect,
  useLayoutEffect,
} from 'react'

type TargetValue<T> = T | undefined | null

type TargetType = HTMLElement | Element | Window | Document

export type BasicTarget<T extends TargetType = Element> =
  | (() => TargetValue<T>)
  | TargetValue<T>
  | RefObject<TargetValue<T>>

export function getTargetElement<T extends TargetType>(
  target: BasicTarget<T>,
  defaultElement?: T,
) {
  if (!isBrowser) {
    return undefined
  }

  if (!target) {
    return defaultElement
  }

  let targetElement: TargetValue<T>

  if (isFunction(target)) {
    targetElement = target()
  } else if ('current' in target) {
    targetElement = target.current
  } else {
    targetElement = target
  }

  return targetElement
}

export function createEffectWithTarget(
  useEffectType: typeof useEffect | typeof useLayoutEffect,
) {
  /**
   *
   * @param effect
   * @param deps
   * @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom
   */
  const useEffectWithTarget = (
    effect: EffectCallback,
    deps: DependencyList,
    target: BasicTarget<any> | BasicTarget<any>[],
  ) => {
    const hasInitRef = useRef(false)

    const lastElementRef = useRef<(Element | null)[]>([])
    const lastDepsRef = useRef<DependencyList>([])

    const unLoadRef = useRef<any>(undefined)

    useEffectType(() => {
      const targets = Array.isArray(target) ? target : [target]
      const els = targets.map((item) => getTargetElement(item))

      // init run
      if (!hasInitRef.current) {
        hasInitRef.current = true
        lastElementRef.current = els
        lastDepsRef.current = deps

        unLoadRef.current = effect()
        return
      }

      if (
        els.length !== lastElementRef.current.length ||
        !isEqual(lastElementRef.current, els) ||
        !isEqual(lastDepsRef.current, deps)
      ) {
        unLoadRef.current?.()

        lastElementRef.current = els
        lastDepsRef.current = deps
        unLoadRef.current = effect()
      }
    })

    useUnmount(() => {
      unLoadRef.current?.()
      // for react-refresh
      hasInitRef.current = false
    })
  }

  return useEffectWithTarget
}

use-effect-with-target.ts
import { useEffect } from 'react'
import { createEffectWithTarget } from '@/registry/lib/create-effect-with-target'

export const useEffectWithTarget = createEffectWithTarget(useEffect)

use-text-selection.ts
import { useRef, useState } from 'react'
import { useEffectWithTarget } from '@/registry/hooks/use-effect-with-target'
import { getTargetElement } from '@/registry/lib/create-effect-with-target'
import type { BasicTarget } from '@/registry/lib/create-effect-with-target'

interface Rect {
  top: number
  left: number
  bottom: number
  right: number
  height: number
  width: number
}
export interface State extends Rect {
  text: string
}

const initRect: Rect = {
  top: Number.NaN,
  left: Number.NaN,
  bottom: Number.NaN,
  right: Number.NaN,
  height: Number.NaN,
  width: Number.NaN,
}

const initState: State = {
  text: '',
  ...initRect,
}

function getRectFromSelection(selection: Selection | null): Rect {
  if (!selection) {
    return initRect
  }

  if (selection.rangeCount < 1) {
    return initRect
  }
  const range = selection.getRangeAt(0)
  const { height, width, top, left, right, bottom } =
    range.getBoundingClientRect()
  return {
    height,
    width,
    top,
    left,
    right,
    bottom,
  }
}

export function useTextSelection(
  target?: BasicTarget<Document | Element>,
): State {
  const [state, setState] = useState(initState)

  const stateRef = useRef(state)
  const isInRangeRef = useRef(false)
  stateRef.current = state

  useEffectWithTarget(
    () => {
      const el = getTargetElement(target, document)
      if (!el) {
        return
      }

      const mouseupHandler = () => {
        let selObj: Selection | null = null
        let text = ''
        let rect = initRect
        if (!window.getSelection) {
          return
        }
        selObj = window.getSelection()
        text = selObj ? selObj.toString() : ''
        if (text && isInRangeRef.current) {
          rect = getRectFromSelection(selObj)
          setState({ ...state, text, ...rect })
        }
      }

      // clear the previous range on any click
      const mousedownHandler = (e: MouseEvent) => {
        // if the mouse button is right, skip it because the selection will be cleared
        if (e.button === 2) {
          return
        }
        if (!window.getSelection) {
          return
        }
        if (stateRef.current.text) {
          setState({ ...initState })
        }
        isInRangeRef.current = false
        const selObj = window.getSelection()
        if (!selObj) {
          return
        }
        selObj.removeAllRanges()
        isInRangeRef.current = el.contains(e.target as Node)
      }

      el.addEventListener('mouseup', mouseupHandler)

      document.addEventListener('mousedown', mousedownHandler)

      return () => {
        el.removeEventListener('mouseup', mouseupHandler)
        document.removeEventListener('mousedown', mousedownHandler)
      }
    },
    [],
    target,
  )

  return state
}

API

interface Rect {
  top: number
  left: number
  bottom: number
  right: number
  height: number
  width: number
}

export interface State extends Rect {
  text: string
}

/**
 * A hook to get the text selection from an element
 * @param target - The target to get the text selection from
 */
export function useTextSelection(
  target?: BasicTarget<Document | Element>,
): State

Credits

Last updated on

On this page