useTextSelection
A hook to get the text selection and its bounding rect from an element
Loading...
Installation
npx shadcn@latest add @shadcnhooks/use-text-selectionpnpm dlx shadcn@latest add @shadcnhooks/use-text-selectionyarn dlx shadcn@latest add @shadcnhooks/use-text-selectionbun x shadcn@latest add @shadcnhooks/use-text-selectionCopy and paste the following code into your project.
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
}import { useEffect } from 'react'
import { createEffectWithTarget } from '@/registry/lib/create-effect-with-target'
export const useEffectWithTarget = createEffectWithTarget(useEffect)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>,
): StateCredits
Last updated on