50 | );
51 | };
52 | ```
53 |
54 | In this example, we have a simple React component with two elements. The first element is the `ReactSelectionPopup` component, which wraps the content of the popup. The second element is a `p` tag that has the class name `selection` and a data attribute `data-meta` to set a metadata. When the user selects the text inside this `p` tag, the popup defined in the `ReactSelectionPopup` component will appear.
55 |
56 | ## Props
57 |
58 | | name | type | description |
59 | | --- | ---- | --- |
60 | | `ref` | `{ current?: { close: () => void } }` | The Ref of popup handler that returns function `close` to force the popup to be closed. |
61 | | `onSelect` | `(text: string, meta?: any) => void` | This is an optional function property that takes two parameters: a string representing the selected text and an optional parameter metadata, which could be a boolean, string, number or object. The function is called when a user selects text in HTML. |
62 | | `children` | `React.ReactNode` | __required__ This property is required and represents child elements to be displayed within the component. |
63 | | `selectionClassName` | `string` | __required__ This property is required and specifies the class name used to identify selectable element(s). |
64 | | `multipleSelection` | `boolean` | This is an optional boolean property that specifies whether multiple elements can be selected at once. The default value is false. |
65 | | `metaAttrName` | `string` | This is an optional string property that represents the name of the metadata attribute associated with the selected text. The metadata value should be JSON stringified. This is useful in case there are multiple metadata attributes for different types of data on the same page. |
66 | | `offsetToLeft` | `number` | This is an optional numerical property representing the offset (in pixels) to move the popup along the x-axis relative to its initial position on the screen. A positive value moves it to the left and a negative value moves it to the right. The default pivot point is the right side of the popup. |
67 | | `offsetToTop` | `number` | This is an optional numerical property representing the offset (in pixels) to move the popup along the y-axis relative to its initial position on the screen. A positive value moves it upwards and a negative value moves it downwards. The default pivot point is the bottom of the popup. |
68 |
69 | ## Contributing
70 |
71 | Contributions are always welcome! If you find a bug or have a feature request, please [open an issue](https://github.com/jasonmz/react-selection-popup/issues/new).
72 |
73 | ## License
74 |
75 | This package is licensed under the [MIT License](https://opensource.org/licenses/MIT).
76 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
2 |
3 | interface ReactSelectionPopupProps {
4 | /**
5 | * This function is called when a user selects texts in html.
6 | * @param text - The text of the selection
7 | * @param meta - Additional metadata associated with the selected text (optional)
8 | */
9 | onSelect?: (text: string, meta?: any) => void
10 | /**
11 | * This function is called when a popup is closed due to focus lost.
12 | */
13 | onClose?: () => void
14 | /**
15 | * This function returns a function to close a popup.
16 | */
17 | children: React.ReactNode
18 | /**
19 | * The className to be used to identify selectable element(s).
20 | */
21 | selectionClassName: string
22 | /**
23 | * Whether multiple elements can be selected at once (default is false).
24 | */
25 | multipleSelection?: boolean
26 | /**
27 | * The name of the metadata attribute associated with the selected text (optional).
28 | * The metadata value should be JSON stringfied.
29 | * @example
...
30 | * ...
31 | * ...
32 | */
33 | metaAttrName?: string
34 | /**
35 | * The offset (in pixels) to the left direction of the screen to reposition the popup. The default pivot x is right of the pop.
36 | */
37 | offsetToLeft?: number
38 | /**
39 | * The offset (in pixels) to the top direction of the screen to reposition the popup. The default pivot y is bottom of the pop.
40 | */
41 | offsetToTop?: number
42 |
43 | id?: string
44 | className?: string
45 | style?: React.CSSProperties
46 | }
47 |
48 | type Size = {
49 | /**
50 | * The width of the element in pixels.
51 | */
52 | width: number
53 | /**
54 | * The height of the element in pixels.
55 | */
56 | height: number
57 | }
58 |
59 | type Position = {
60 | /**
61 | * The x-coordinate of the upper-left corner of the element.
62 | */
63 | x: number
64 | /**
65 | * The y-coordinate of the upper-left corner of the element.
66 | */
67 | y: number
68 | }
69 |
70 | export interface HandleRef {
71 | close: () => void
72 | }
73 |
74 | const ReactSelectionPopup: React.ForwardRefRenderFunction = (
75 | {
76 | onSelect,
77 | onClose,
78 | children,
79 | selectionClassName,
80 | multipleSelection = true,
81 | metaAttrName,
82 | offsetToLeft = 0,
83 | offsetToTop = 0,
84 | ...rest
85 | },
86 | ref
87 | ) => {
88 | const [size, setSize] = useState({ width: 0, height: 0 })
89 | const [position, setPosition] = useState(null)
90 |
91 | const popupRef = useRef(null)
92 | const positionRef = useRef(null)
93 |
94 | positionRef.current = position
95 |
96 | const isPopupContent = useCallback((e: any) => {
97 | let node: HTMLElement | null = e.target as HTMLElement
98 |
99 | // Check if the target div is popup which is the exception case
100 | while (node != null) {
101 | if (node === popupRef.current) {
102 | return true
103 | }
104 |
105 | node = node.parentNode as HTMLElement
106 | }
107 |
108 | return false
109 | }, [])
110 |
111 | const close = useCallback(() => {
112 | setPosition(null)
113 | onClose?.()
114 | }, [onClose])
115 |
116 | useEffect(() => {
117 | const onMouseUp = (e: any) => {
118 | const selection = window.getSelection()
119 | if (selection !== null) {
120 | const { anchorNode, focusNode } = selection
121 |
122 | if (anchorNode !== null && focusNode !== null) {
123 | if (anchorNode.parentElement !== null && anchorNode.parentElement.classList.contains(selectionClassName)) {
124 | const text = selection.toString()
125 | const meta = JSON.parse(e.target.getAttribute(metaAttrName))
126 |
127 | if (text) {
128 | if (!metaAttrName || meta) {
129 | if (selection.rangeCount !== 0) {
130 | if (anchorNode.isEqualNode(focusNode) || multipleSelection) {
131 | const range = selection.getRangeAt(0)
132 |
133 | const { right: x, top: y } = range.getBoundingClientRect()
134 |
135 | // TODO: position {x, y} should come from the first line of selection
136 |
137 | setPosition({ x, y })
138 | onSelect?.(text, meta)
139 | return
140 | } else {
141 | selection.removeAllRanges()
142 | }
143 | }
144 | }
145 |
146 | if (!isPopupContent(e)) {
147 | close()
148 | }
149 | } else {
150 | setPosition(null)
151 | }
152 | }
153 | }
154 | }
155 | }
156 |
157 | const onMousedown = (e: any) => {
158 | const selection = window.getSelection()
159 |
160 | if (!isPopupContent(e) && positionRef.current !== null && selection !== null) {
161 | selection.removeAllRanges()
162 | close()
163 | }
164 | }
165 |
166 | const onScroll = () => {
167 | close()
168 | }
169 |
170 | window.addEventListener('mouseup', onMouseUp)
171 | window.addEventListener('mousedown', onMousedown)
172 | window.addEventListener('scroll', onScroll)
173 |
174 | return () => {
175 | window.removeEventListener('mouseup', onMouseUp)
176 | window.removeEventListener('mousedown', onMousedown)
177 | window.removeEventListener('scroll', onScroll)
178 | }
179 | }, [close, onSelect, onClose, isPopupContent, position, multipleSelection, selectionClassName, metaAttrName])
180 |
181 | useEffect(() => {
182 | if (popupRef.current) {
183 | const width = popupRef.current.offsetWidth
184 | const height = popupRef.current.offsetHeight
185 |
186 | setSize({ width, height })
187 | }
188 | }, [children, position, popupRef])
189 |
190 | useImperativeHandle(ref, (): HandleRef => {
191 | return {
192 | close
193 | }
194 | })
195 |
196 | if (position === null) return <>>
197 |
198 | const left = position.x - size.width - offsetToLeft
199 | const top = position.y - size.height - offsetToTop
200 |
201 | return (
202 |