├── .eslintrc.cjs
├── .github
└── workflows
│ └── tests.js.yml
├── .gitignore
├── README.md
├── dts-bundle-generator.config.ts
├── index.html
├── package-lock.json
├── package.json
├── public
├── preview.png
└── vite.svg
├── src
├── App.tsx
├── HighlightMenu.tsx
├── MenuButton.tsx
├── classNames.ts
├── index.ts
├── main.tsx
├── tests
│ └── useGetSelectionDetails.test.tsx
├── types.ts
├── useGetSelectionDetails.tsx
├── utils.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.pages.ts
└── vite.config.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: { browser: true, es2020: true },
3 | extends: [
4 | "eslint:recommended",
5 | "plugin:@typescript-eslint/recommended",
6 | "plugin:react-hooks/recommended",
7 | ],
8 | parser: "@typescript-eslint/parser",
9 | parserOptions: { ecmaVersion: "latest", sourceType: "module" },
10 | plugins: ["react-refresh"],
11 | rules: {
12 | "react-refresh/only-export-components": "warn",
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/.github/workflows/tests.js.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3 |
4 | name: Tests
5 |
6 | on:
7 | push:
8 | branches: ["main"]
9 | pull_request:
10 | branches: ["main"]
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 |
16 | strategy:
17 | matrix:
18 | node-version: [18.x]
19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
20 |
21 | steps:
22 | - uses: actions/checkout@v3
23 | - name: Use Node.js ${{ matrix.node-version }}
24 | uses: actions/setup-node@v3
25 | with:
26 | node-version: ${{ matrix.node-version }}
27 | cache: "npm"
28 | - run: npm ci
29 | - run: npm test
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | pages
13 | coverage
14 | dist-ssr
15 | *.local
16 |
17 | # Editor directories and files
18 | .vscode/*
19 | !.vscode/extensions.json
20 | .idea
21 | .DS_Store
22 | *.suo
23 | *.ntvs*
24 | *.njsproj
25 | *.sln
26 | *.sw?
27 |
28 | .npmrc
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-highlight-menu
2 |
3 | [](https://www.npmjs.com/package/react-highlight-menu)
4 | 
5 |
6 | A context menu that appears after highlighting or selecting text.
7 | _Similar to how the menu on Medium works._
8 |
9 | 
10 |
11 | - [Demo](https://asyndesis.github.io/react-highlight-menu/) - Buttons and icons from [ChakraUI](https://chakra-ui.com/)
12 |
13 | ## Installation
14 |
15 | Run one of the following commands:
16 |
17 | - `npm install react-highlight-menu`
18 | - `yarn add react-highlight-menu`
19 |
20 | Then use it in your app:
21 |
22 | ```jsx
23 | import React from "react";
24 | /* Library comes with some super basic MenuButtons. You can import default styles and use them as a starting point.
25 | The example below shows how to use the `classNames` prop to style the menu, popover, and arrow elements with something like Tailwind.*/
26 | import { HighlightMenu, MenuButton } from "react-highlight-menu";
27 |
28 | export default function App() {
29 | return (
30 |
31 | (
40 | <>
41 |
46 | setClipboard(selectedText, () => {
47 | alert("Copied to clipboard");
48 | })
49 | }
50 | />
51 | {
55 | window.open(
56 | `https://www.google.com/search?q=${encodeURIComponent(
57 | selectedText
58 | )}`
59 | );
60 | }}
61 | icon="magnifying-glass"
62 | />
63 | setMenuOpen(false)}
67 | icon="x-mark"
68 | />
69 | >
70 | )}
71 | allowedPlacements={["top", "bottom"]}
72 | />
73 |
74 | );
75 | }
76 | ```
77 |
78 | ## Props
79 |
80 | - **target** - can either be a querySelector string, or a react ref.
81 | - **styles** - several css attributes can be applied for styling. (See demo)
82 | - **menu** - `({ selectedHtml, selectedText, setMenuOpen, setClipboard }) => <>Buttons>`
83 | - **allowedPlacements** - array of allowed placements `-'auto' | 'auto-start' | 'auto-end' | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'right' | 'right-start' | 'right-end' | 'left' | 'left-start' | 'left-end'`
84 | - **offset** - distance in pixels from highlighted words. `10`
85 | - **zIndex** - zIndex of the popover `999999999`
86 | - **withoutStyles** - if true, the menu will be only styled with props that help it to function. And we the following classnames can be targeted for styling:
87 | - **classNames** - an object where classnames can be passed to the menu, popover, and arrow elements.
88 | - `menu` - for the menu element
89 | - `popover` - for the popover element
90 | - `arrow` - for the arrow element
91 |
92 | ## These classnames are also targetable for styling if you need more control over the styles
93 |
94 | - `.rhm-popover` - for the popover element
95 | - `.rhm-menu` - for the menu element
96 | - `.rhm-button` - for the button element
97 | - `.rhm-arrow` - for the arrow element
98 | - `.rhm-anchor` - for the anchor element
99 |
100 | ## Development
101 |
102 | ```bash
103 | npm install
104 | npm run dev
105 | ```
106 |
--------------------------------------------------------------------------------
/dts-bundle-generator.config.ts:
--------------------------------------------------------------------------------
1 | const config = {
2 | entries: [
3 | {
4 | filePath: "./src/index.ts",
5 | outFile: "./dist/index.d.ts",
6 | noCheck: false,
7 | output: {
8 | umdModuleName: "ReactHighlightMenu",
9 | },
10 | },
11 | ],
12 | };
13 |
14 | module.exports = config;
15 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | React Highlight Menu
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-highlight-menu",
3 | "version": "2.1.1",
4 | "files": [
5 | "dist",
6 | "README.md"
7 | ],
8 | "main": "dist/index.cjs.js",
9 | "module": "dist/index.es.js",
10 | "types": "dist/index.d.ts",
11 | "license": "MIT",
12 | "repository": {
13 | "type": "git",
14 | "url": "https://github.com/asyndesis/react-highlight-menu"
15 | },
16 | "homepage": "https://asyndesis.github.io/react-highlight-menu",
17 | "scripts": {
18 | "dev": "vite",
19 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
20 | "preview": "vite preview",
21 | "test": "vitest",
22 | "coverage": "vitest run --coverage",
23 | "build": "rm -rf dist && rm -rf pages && tsc && vite build --config vite.config.ts && vite build --config vite.config.pages.ts && dts-bundle-generator --config ./dts-bundle-generator.config.ts",
24 | "deploy:pages": "gh-pages -d pages"
25 | },
26 | "dependencies": {
27 | "@floating-ui/react": "^0.27.4",
28 | "clsx": "^2.0.0"
29 | },
30 | "devDependencies": {
31 | "@babel/cli": "^7.22.5",
32 | "@babel/core": "^7.22.5",
33 | "@babel/preset-react": "^7.22.5",
34 | "@chakra-ui/icons": "^2.0.19",
35 | "@chakra-ui/react": "^2.7.1",
36 | "@emotion/react": "^11.11.1",
37 | "@emotion/styled": "^11.11.0",
38 | "@testing-library/jest-dom": "^5.16.5",
39 | "@testing-library/react": "^14.0.0",
40 | "@types/react": "^18.0.37",
41 | "@types/react-dom": "^18.0.11",
42 | "@types/react-syntax-highlighter": "^15.5.7",
43 | "@typescript-eslint/eslint-plugin": "^5.59.0",
44 | "@typescript-eslint/parser": "^5.59.0",
45 | "@vitejs/plugin-react-swc": "^3.0.0",
46 | "dts-bundle-generator": "^8.0.1",
47 | "eslint": "^8.38.0",
48 | "eslint-plugin-react-hooks": "^4.6.0",
49 | "eslint-plugin-react-refresh": "^0.3.4",
50 | "framer-motion": "^10.12.17",
51 | "fs-extra": "^11.1.1",
52 | "gh-pages": "^5.0.0",
53 | "jsdom": "^22.1.0",
54 | "react": "^18.2.0",
55 | "react-dom": "^18.2.0",
56 | "react-syntax-highlighter": "^15.5.0",
57 | "rollup-plugin-md": "^1.0.1",
58 | "typescript": "^5.0.2",
59 | "vite": "^4.3.9",
60 | "vite-plugin-dts": "^3.0.0-beta.3",
61 | "vitest": "^0.32.2"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/public/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asyndesis/react-highlight-menu/abea1f1c79ccd686a5c9ddd0fc4a81000b989e67/public/preview.png
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, CSSProperties } from "react";
2 | import { HighlightMenu, MenuButton } from ".";
3 | import {
4 | CopyIcon,
5 | SearchIcon,
6 | CloseIcon,
7 | ChevronDownIcon,
8 | ChevronUpIcon,
9 | } from "@chakra-ui/icons";
10 | import {
11 | ChakraProvider,
12 | IconButton,
13 | Flex,
14 | Heading,
15 | Text,
16 | Card,
17 | Tooltip,
18 | CardBody,
19 | FormControl,
20 | FormLabel,
21 | Switch,
22 | Input,
23 | Textarea,
24 | Box,
25 | Accordion,
26 | AccordionItem,
27 | AccordionButton,
28 | AccordionPanel,
29 | } from "@chakra-ui/react";
30 | import { Global } from "@emotion/react";
31 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
32 | import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
33 |
34 | import { MenuArgs } from "./types";
35 |
36 | const MENU_STYLES: Record = {
37 | black: {
38 | borderColor: "black",
39 | backgroundColor: "black",
40 | boxShadow: "0px 5px 5px 0px rgba(0, 0, 0, 0.15)",
41 | zIndex: 10,
42 | borderRadius: "5px",
43 | padding: "3px",
44 | },
45 | white: {
46 | borderColor: "white",
47 | backgroundColor: "white",
48 | boxShadow: "0px 5px 5px 0px rgba(0, 0, 0, 0.15)",
49 | zIndex: 10,
50 | borderRadius: "5px",
51 | padding: "3px",
52 | },
53 | pink: {
54 | borderColor: "#D53F8C",
55 | backgroundColor: "#D53F8C",
56 | boxShadow: "0px 5px 5px 0px rgba(0, 0, 0, 0.15)",
57 | zIndex: 10,
58 | borderRadius: "5px",
59 | padding: "3px",
60 | },
61 | };
62 |
63 | const useGetMenu =
64 | (styleColor: any) =>
65 | ({ selectedText = "", setMenuOpen, setClipboard }: MenuArgs) => {
66 | const color = styleColor === "white" ? null : styleColor;
67 | return (
68 |
69 |
70 | {
74 | setClipboard(selectedText, () => {
75 | alert("Copied to clipboard");
76 | });
77 | }}
78 | icon={ }
79 | />
80 |
81 |
82 | {
86 | window.open(
87 | `https://www.google.com/search?q=${encodeURIComponent(
88 | selectedText
89 | )}`
90 | );
91 | }}
92 | icon={ }
93 | />
94 |
95 |
96 | {
100 | setMenuOpen(false);
101 | }}
102 | icon={ }
103 | />
104 |
105 |
106 | );
107 | };
108 |
109 | const GithubCorner = () => {
110 | return (
111 |
116 |
131 |
132 |
138 |
143 |
144 |
145 | );
146 | };
147 |
148 | // Example components
149 | const TargetExample = () => {
150 | const [useTargetClass, setUseTargetClass] = useState(true);
151 | const menuRef = useRef(null);
152 | const fullMenu = useGetMenu("white");
153 |
154 | return (
155 |
156 |
162 |
163 | The target property supports both css-selectors and refs.
164 |
165 |
166 | setUseTargetClass(e.target.checked)}
171 | />
172 |
173 | target=".target-example"
174 |
175 | setUseTargetClass(!e.target.checked)}
180 | />
181 |
182 | {`target={ref}`}
183 |
184 |
185 |
186 |
187 | {`const TargetExample = () => {
188 | ${
189 | !useTargetClass ? "const menuRef = useRef();" : "/* Using a css selector */"
190 | }
191 | return (
192 | <>Buttons go here>}
196 | />
197 |
198 | Selecting this text will show the menu!
199 |
200 | );
201 | };`}
202 |
203 |
204 | );
205 | };
206 |
207 | const MenuExample = () => {
208 | const fullMenu = useGetMenu("white");
209 |
210 | return (
211 |
212 |
218 |
219 | The menu property provides the state of the component through function
220 | arguments. A setClipboard utility function is also
221 | provided.
222 |
223 |
224 | {`const MenuExample = () => {
225 | return (
226 |
234 |
235 | setClipboard(selectedText, () => {
237 | alert("Copied to clipboard");
238 | })}
239 | />
240 |
242 | window.open("https://www.google.com/search?q="+selectedText")}
243 | />
244 | setMenuOpen(false)}
246 | />
247 |
248 | }
249 | />
250 | );
251 | };`}
252 |
253 |
254 | );
255 | };
256 |
257 | const StylesExample = () => {
258 | const [styleColor, setStyleColor] = useState("pink");
259 | const fullMenu = useGetMenu(styleColor);
260 |
261 | return (
262 |
263 |
269 |
270 | Change the look of the popover with several style properties. Note that
271 | buttons are not included. We are using ChakraUI behind the scenes here.
272 |
273 |
274 | {Object.entries(MENU_STYLES).map(([key]) => {
275 | return (
276 |
277 | setStyleColor(key)}
282 | />
283 |
288 | {key}
289 |
290 |
291 | );
292 | })}
293 |
294 |
295 |
296 | {`const StylesExample = () => {
297 | return (
298 | <>Buttons go here>}
305 | />
306 | );
307 | };`}
308 |
309 |
310 | );
311 | };
312 |
313 | const InputExample = () => {
314 | const fullMenu = useGetMenu("white");
315 |
316 | return (
317 |
318 |
324 |
325 | The popover should also work inside of Input and TextArea components,
326 | but has limited support for the X,Y due to browser constraints.
327 |
328 |
329 |
330 |
331 |
332 | {`const InputExample = () => {
333 | return (
334 | <>
335 |
336 |
337 | >
338 | );
339 | };`}
340 |
341 |
342 | );
343 | };
344 |
345 | const TailwindExample = () => {
346 | return (
347 |
348 |
393 | (
402 | <>
403 |
408 | setClipboard(selectedText, () => {
409 | alert("Copied to clipboard");
410 | })
411 | }
412 | />
413 | {
417 | window.open(
418 | `https://www.google.com/search?q=${encodeURIComponent(
419 | selectedText
420 | )}`
421 | );
422 | }}
423 | icon="magnifying-glass"
424 | />
425 | setMenuOpen(false)}
429 | icon="x-mark"
430 | />
431 | >
432 | )}
433 | allowedPlacements={["top", "bottom"]}
434 | />
435 |
436 | This example uses the built-in MenuButton component
437 | with custom classNames. But you can use normal talwind buttons if you
438 | prefer that. The withoutStyles can be set to true if
439 | the native styles are interfering with your custom classes.
440 |
441 |
442 | {`import { HighlightMenu, MenuButton } from "react-highlight-menu";
443 |
444 | const TailwindStyleExample = () => {
445 | return (
446 |
447 | (
456 | <>
457 |
462 | setClipboard(selectedText, () => {
463 | alert("Copied to clipboard");
464 | })
465 | }
466 | />
467 | {
471 | window.open(
472 | \`https://www.google.com/search?q=\${encodeURIComponent(
473 | selectedText
474 | )}\`
475 | );
476 | }}
477 | icon="magnifying-glass"
478 | />
479 | setMenuOpen(false)}
483 | icon="x-mark"
484 | />
485 | >
486 | )}
487 | allowedPlacements={["top", "bottom"]}
488 | />
489 |
490 | );
491 | };`}
492 |
493 |
494 | );
495 | };
496 |
497 | function App() {
498 | const accordionItems = [
499 | {
500 | title: "Target Property",
501 | component: ,
502 | },
503 | {
504 | title: "Menu Properties",
505 | component: ,
506 | },
507 | {
508 | title: "Style with styles prop",
509 | component: ,
510 | },
511 | {
512 | title: "Style with Tailwind",
513 | component: ,
514 | },
515 | {
516 | title: "Input Fields Example",
517 | component: ,
518 | },
519 | ];
520 |
521 | return (
522 |
523 |
524 |
532 |
533 |
534 |
535 |
536 | React highlight menu demos
537 |
538 | Open an accordion and highlight text anywhere on the page to
539 | reveal the context menu.
540 |
541 |
542 | {accordionItems.map((item, index) => (
543 |
544 | {({ isExpanded }) => (
545 | <>
546 |
547 |
548 | {item.title}
549 |
550 | {isExpanded ? (
551 |
552 | ) : (
553 |
554 | )}
555 |
556 | {item.component}
557 | >
558 | )}
559 |
560 | ))}
561 |
562 |
563 |
564 |
565 |
566 | );
567 | }
568 |
569 | export default App;
570 |
--------------------------------------------------------------------------------
/src/HighlightMenu.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState, CSSProperties } from "react";
2 | import {
3 | useFloating,
4 | autoUpdate,
5 | offset as middlewareOffset,
6 | autoPlacement as middlewareAutoPlacement,
7 | shift as middlewareShift,
8 | arrow as middlewareArrow,
9 | useDismiss,
10 | useRole,
11 | useClick,
12 | useInteractions,
13 | FloatingFocusManager,
14 | useId,
15 | FloatingArrow,
16 | Placement,
17 | } from "@floating-ui/react";
18 | import { useGetSelectionDetails, getPopoverCoordinates, setClipboard } from ".";
19 | import {
20 | MENU_ANCHOR_CLASS_NAME,
21 | MENU_ARROW_CLASS_NAME,
22 | MENU_CLASS_NAME,
23 | MENU_POPOVER_CLASS_NAME,
24 | } from "./classNames";
25 | import { MenuArgs, SetMenuOpen, TargetSelector } from "./types";
26 | import { clsx } from "clsx";
27 |
28 | const ARROW_WIDTH = 10;
29 | const ARROW_HEIGHT = 5;
30 |
31 | const getMenuStyles = (styles: CSSProperties = {}) => {
32 | const defaultStyles: CSSProperties = {
33 | borderStyle: "solid",
34 | borderWidth: 1,
35 | borderColor: "#CCC",
36 | backgroundColor: "#FFFFFF",
37 | boxShadow: "0px 5px 5px 0px rgba(0, 0, 0, 0.15)",
38 | borderRadius: 5,
39 | padding: 5,
40 | margin: 0,
41 | display: "flex",
42 | gap: 5,
43 | ...styles,
44 | };
45 |
46 | defaultStyles.margin = -(defaultStyles.borderWidth ?? 0);
47 |
48 | return defaultStyles;
49 | };
50 |
51 | type MainArgs = {
52 | target: TargetSelector;
53 | menu: (props: MenuArgs) => React.ReactNode;
54 | styles?: CSSProperties;
55 | offset?: number;
56 | allowedPlacements: Array;
57 | zIndex?: number;
58 | className?: string;
59 | withoutStyles?: boolean;
60 | classNames?: {
61 | menu: string;
62 | popover: string;
63 | arrow: string;
64 | };
65 | };
66 |
67 | function HighlightMenu({
68 | target,
69 | menu,
70 | offset = 2,
71 | styles,
72 | allowedPlacements,
73 | zIndex = 999999999,
74 | withoutStyles = false,
75 | classNames,
76 | ...props
77 | }: MainArgs) {
78 | const selection = useGetSelectionDetails(target);
79 | const [menuOpen, setMenuOpen] = useState(null);
80 | const clientRect = getPopoverCoordinates(selection?.range);
81 | const arrowRef = useRef(null);
82 | const menuStyles = withoutStyles ? {} : getMenuStyles(styles);
83 | const borderWidth = withoutStyles ? 0 : (menuStyles.borderWidth as number);
84 |
85 | /* Floating menu hook initiations */
86 | const { refs, floatingStyles, context } = useFloating({
87 | open: !!menuOpen,
88 | middleware: [
89 | middlewareOffset(offset + ARROW_HEIGHT + borderWidth),
90 | middlewareAutoPlacement({ allowedPlacements }),
91 | middlewareShift(),
92 | middlewareArrow({
93 | element: arrowRef,
94 | }),
95 | ],
96 | whileElementsMounted: autoUpdate,
97 | });
98 |
99 | const click = useClick(context);
100 | const dismiss = useDismiss(context);
101 | const role = useRole(context);
102 |
103 | const { getReferenceProps, getFloatingProps } = useInteractions([
104 | click,
105 | dismiss,
106 | role,
107 | ]);
108 |
109 | const headingId = useId();
110 |
111 | /* When the selection changes, the menu will show. */
112 | useEffect(() => {
113 | setMenuOpen(clientRect);
114 | //eslint-disable-next-line
115 | }, [JSON.stringify(clientRect)]);
116 |
117 | return (
118 | <>
119 |
130 | {menuOpen && selection && (
131 |
132 | {
139 | e.stopPropagation();
140 | e.preventDefault();
141 | }}
142 | onMouseUp={(e) => {
143 | e.stopPropagation();
144 | e.preventDefault();
145 | }}
146 | >
147 |
152 | {menu({ ...selection, setMenuOpen, setClipboard })}
153 |
154 |
164 |
165 |
166 | )}
167 | >
168 | );
169 | }
170 |
171 | export default HighlightMenu;
172 |
--------------------------------------------------------------------------------
/src/MenuButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { MENU_BUTTON_CLASS_NAME } from "./classNames";
3 | import { clsx } from "clsx";
4 | const ICONS: Record = {
5 | "magnifying-glass": (
6 |
15 |
20 |
21 | ),
22 | clipboard: (
23 |
32 |
37 |
38 | ),
39 | "x-mark": (
40 |
49 |
54 |
55 | ),
56 | };
57 |
58 | export const DEFAULT_BUTTON_STYLES: React.CSSProperties = {
59 | backgroundColor: "#3b82f6",
60 | color: "#fff",
61 | fontWeight: "bold",
62 | padding: "0.5rem 1rem",
63 | borderRadius: "0.25rem",
64 | display: "flex",
65 | alignItems: "center",
66 | gap: "0.5rem",
67 | cursor: "pointer",
68 | border: "none",
69 | };
70 |
71 | interface MenuButtonProps {
72 | onClick?: () => void;
73 | disabled?: boolean;
74 | children?: React.ReactNode;
75 | style?: React.CSSProperties;
76 | svg?: React.ReactNode;
77 | icon?: string;
78 | title?: string;
79 | className?: string;
80 | withDefaultStyles?: boolean;
81 | }
82 |
83 | const MenuButton: React.FC = ({
84 | children,
85 | svg,
86 | icon,
87 | className,
88 | style,
89 | ...props
90 | }) => {
91 | const theIcon = icon ? ICONS?.[icon] : svg;
92 |
93 | return (
94 |
99 | {theIcon}
100 | {children}
101 |
102 | );
103 | };
104 |
105 | export default MenuButton;
106 |
--------------------------------------------------------------------------------
/src/classNames.ts:
--------------------------------------------------------------------------------
1 | export const MENU_BUTTON_CLASS_NAME = "rhm-button";
2 | export const MENU_CLASS_NAME = "rhm-menu";
3 | export const MENU_POPOVER_CLASS_NAME = "rhm-popover";
4 | export const MENU_ARROW_CLASS_NAME = "rhm-arrow";
5 | export const MENU_ANCHOR_CLASS_NAME = "rhm-anchor";
6 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as HighlightMenu } from "./HighlightMenu.tsx";
2 | export { default as MenuButton } from "./MenuButton.tsx";
3 | export { default as useGetSelectionDetails } from "./useGetSelectionDetails.tsx";
4 | export {
5 | setClipboard,
6 | getPopoverCoordinates,
7 | getSelectionDetails,
8 | resolveTargets,
9 | isSelectionWithinTarget,
10 | } from "./utils.ts";
11 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.tsx";
4 |
5 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
6 |
7 |
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/src/tests/useGetSelectionDetails.test.tsx:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 | import { fireEvent, render, screen } from "@testing-library/react";
3 | import { useGetSelectionDetails } from "../";
4 | import "@testing-library/jest-dom";
5 |
6 | import { TargetSelector } from "../types";
7 |
8 | const HIGLIGHT_RANGE = { START: 0, END: 5 };
9 | const TARGET_CLASS_NAME = "testing-target-class";
10 | const TEXT_CONTENT = "Here is some example selected text.";
11 | const TEXT_SELECTION = TEXT_CONTENT.substring(
12 | HIGLIGHT_RANGE.START,
13 | HIGLIGHT_RANGE.END
14 | );
15 |
16 | /* Create a component that dumps the hook state to the dom so that we can actually test it */
17 | function SelectionDetailsComponent({ target }: { target: TargetSelector }) {
18 | const selectionDetails = useGetSelectionDetails(target);
19 | const { selectedText } = selectionDetails ?? {};
20 |
21 | return selectedText: {selectedText}
;
22 | }
23 |
24 | /* Perform a text selection event on any non-input dom node */
25 | function performTextSelection(content = TEXT_CONTENT) {
26 | const element = screen.getByText(content);
27 |
28 | const selectEvent = new Event("selectionchange", { bubbles: true });
29 | const mouseUpEvent = new Event("mouseup", { bubbles: true });
30 | const range = document.createRange();
31 | const textNode = element.firstChild;
32 |
33 | if (textNode) {
34 | range.setStart(textNode, HIGLIGHT_RANGE.START); // Start position of selected text
35 | range.setEnd(textNode, HIGLIGHT_RANGE.END); // End position of selected text
36 | window.getSelection()?.removeAllRanges();
37 | window.getSelection()?.addRange(range);
38 | fireEvent(element, selectEvent);
39 | fireEvent(element, mouseUpEvent);
40 | }
41 | }
42 |
43 | /* Perform a dom selection event within an input element */
44 | function performInputSelection(content = TEXT_CONTENT) {
45 | const inputElement = screen.getByDisplayValue(content) as HTMLInputElement;
46 |
47 | inputElement.setSelectionRange(HIGLIGHT_RANGE.START, HIGLIGHT_RANGE.END);
48 | inputElement.focus();
49 |
50 | const selectEvent = new Event("selectionchange", { bubbles: true });
51 | const mouseUpEvent = new Event("mouseup", { bubbles: true });
52 |
53 | fireEvent(inputElement, selectEvent);
54 | fireEvent(inputElement, mouseUpEvent);
55 | }
56 |
57 | /* Test to make sure highlighting in the browser works first */
58 | describe("Text highlighting", () => {
59 | test("works in the browser with p tags", () => {
60 | render({TEXT_CONTENT}
);
61 | performTextSelection();
62 | // Assert that the window has a selection
63 | const selection = window.getSelection();
64 | expect(selection?.toString()).toBe(TEXT_SELECTION);
65 | });
66 | test("works in the browser with nested div tags", () => {
67 | render(
68 |
69 |
70 |
{TEXT_CONTENT}
71 |
72 | );
73 | performTextSelection();
74 | // Assert that the window has a selection
75 | const selection = window.getSelection();
76 | expect(selection?.toString()).toBe(TEXT_SELECTION);
77 | });
78 | });
79 |
80 | /* Now we can test to see if the hook is actually working. */
81 | describe("useGetSelectionDetails hook", () => {
82 | test("gets selectedText from a p tag with a class", () => {
83 | render(
84 | <>
85 |
86 | {TEXT_CONTENT}
87 | >
88 | );
89 | performTextSelection();
90 | const selectedText = screen.getByText(/selectedText/i);
91 |
92 | expect(selectedText.textContent).toBe(`selectedText: ${TEXT_SELECTION}`);
93 | });
94 |
95 | test("gets selectedText from a input tag with a class", () => {
96 | render(
97 | <>
98 |
99 |
100 |
101 |
102 | >
103 | );
104 | performInputSelection();
105 |
106 | const selectedText = screen.getByText(/selectedText/i);
107 | expect(selectedText.textContent).toBe(`selectedText: ${TEXT_SELECTION}`);
108 | });
109 |
110 | test("gets selectedText from a input tag with a class", () => {
111 | render(
112 | <>
113 |
114 |
115 |
116 |
117 | >
118 | );
119 | performInputSelection();
120 |
121 | const selectedText = screen.getByText(/selectedText/i);
122 | expect(selectedText.textContent).toBe(`selectedText: ${TEXT_SELECTION}`);
123 | });
124 | });
125 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { MutableRefObject, RefObject } from "react";
2 |
3 | export type SelectionDetails =
4 | | {
5 | baseNode?: Node | null;
6 | extentNode?: Node | null;
7 | range?: Range;
8 | selectedHtml?: string;
9 | selectedText?: string;
10 | }
11 | | null
12 | | undefined;
13 |
14 | export type TargetSelector =
15 | | string
16 | | { current: HTMLElement }
17 | | MutableRefObject
18 | | RefObject;
19 |
20 | export type TargetElements =
21 | | NodeList
22 | | (HTMLElement | HTMLDivElement | null | undefined)[]
23 | | null;
24 |
25 | export type HMClientRect = {
26 | top: number;
27 | left: number;
28 | width: number;
29 | height: number;
30 | };
31 |
32 | export type SuccessCallback = () => void;
33 | export type ErrorCallback = (error: any) => void;
34 |
35 | export type SetClipboard = (
36 | text: string | undefined,
37 | onSuccess?: SuccessCallback,
38 | onError?: ErrorCallback
39 | ) => Promise | false;
40 |
41 | export type SetMenuOpen = HMClientRect | false | null;
42 |
43 | export interface MenuArgs {
44 | selectedHtml?: string | undefined;
45 | selectedText?: string | undefined;
46 | setMenuOpen: React.Dispatch;
47 | setClipboard: SetClipboard;
48 | }
49 |
--------------------------------------------------------------------------------
/src/useGetSelectionDetails.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useState } from "react";
2 | import {
3 | getSelectionDetails,
4 | resolveTargets,
5 | isSelectionWithinTarget,
6 | } from ".";
7 | import { SelectionDetails, TargetSelector } from "./types";
8 |
9 | export function useGetSelectionDetails(target: TargetSelector) {
10 | const [state, setState] = useState();
11 |
12 | const onSelectionChange = () => {
13 | const selection = getSelectionDetails();
14 | if (!selection?.range) {
15 | setState(null);
16 | }
17 | };
18 |
19 | useLayoutEffect(() => {
20 | const updateAnchorPos = () => {
21 | const targets = resolveTargets(target);
22 | const selection = getSelectionDetails();
23 | if (isSelectionWithinTarget(targets, selection)) {
24 | setState(selection);
25 | }
26 | };
27 | const onWindowScroll = () => {
28 | updateAnchorPos();
29 | window.dispatchEvent(new CustomEvent("scroll"));
30 | };
31 |
32 | document.addEventListener("mouseup", updateAnchorPos);
33 | document.addEventListener("selectionchange", onSelectionChange);
34 | window.addEventListener("resize", updateAnchorPos);
35 | document.addEventListener("scroll", onWindowScroll, {
36 | capture: true,
37 | });
38 | return () => {
39 | document.removeEventListener("mouseup", updateAnchorPos);
40 | document.removeEventListener("selectionchange", onSelectionChange);
41 | window.removeEventListener("resize", updateAnchorPos);
42 | document.removeEventListener("scroll", onWindowScroll, {
43 | capture: true,
44 | });
45 | };
46 | }, [target]);
47 |
48 | return state;
49 | }
50 |
51 | export default useGetSelectionDetails;
52 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SetClipboard,
3 | HMClientRect,
4 | TargetSelector,
5 | TargetElements,
6 | SelectionDetails,
7 | } from "./types";
8 |
9 | /* Used to position the HiglightMenu. Transforms ranges and deals with nullish values. */
10 | const getPopoverCoordinates = (
11 | range: Range | undefined
12 | ): HMClientRect | null => {
13 | if (!range) return null;
14 | const { top, left, width, height } = range?.getBoundingClientRect() ?? {};
15 | return {
16 | top,
17 | left,
18 | width,
19 | height,
20 | };
21 | };
22 |
23 | /* Simple clipboard support. TODO: More support for HTML text */
24 | const setClipboard: SetClipboard = (text, onSuccess, onError) => {
25 | if (!navigator?.clipboard) return false;
26 | if (!text) return false;
27 | return navigator.clipboard
28 | .writeText(text)
29 | .then(() => {
30 | if (onSuccess) onSuccess();
31 | return true;
32 | })
33 | .catch((error) => {
34 | if (onError) onError(error);
35 | return false;
36 | });
37 | };
38 |
39 | /* Extrapolates elements from either a React Ref object or a selector string */
40 | function resolveTargets(target: TargetSelector): TargetElements {
41 | if (typeof target === "string") {
42 | /* Class and IDs */
43 | return document.querySelectorAll(target);
44 | } else {
45 | /* Ref Objects */
46 | return [target?.current].filter(Boolean);
47 | }
48 | }
49 |
50 | /* Input and Textarea selections are rendered in the browsers native UI. We need to handle them differently */
51 | function getSelectionDetails(): SelectionDetails {
52 | return getDomSelectionDetails() || getUISelection();
53 | }
54 |
55 | /* Get the selection for browser native-UI elements */
56 | function getUISelection(): SelectionDetails {
57 | const focusedElement = document?.activeElement as HTMLInputElement;
58 |
59 | const selectedText = focusedElement?.value?.substring?.(
60 | focusedElement?.selectionStart || 0,
61 | focusedElement?.selectionEnd || 0
62 | );
63 |
64 | const selectionDetails: SelectionDetails = {
65 | baseNode: focusedElement,
66 | extentNode: focusedElement,
67 | range: focusedElement as unknown as Range,
68 | selectedText,
69 | };
70 |
71 | return selectedText ? selectionDetails : null;
72 | }
73 |
74 | /* Get the selection for non-UI elements */
75 | function getDomSelectionDetails(): SelectionDetails {
76 | if (window?.getSelection?.()?.isCollapsed) return null;
77 | const Serializer = new XMLSerializer();
78 | const selection = window?.getSelection();
79 | const range = selection?.getRangeAt(0);
80 | const selectedNode = range?.cloneContents() as Node;
81 | const selectedHtml = Serializer.serializeToString(selectedNode);
82 | const selectedText = selection?.toString();
83 | const selectionDetails: SelectionDetails = {
84 | baseNode: selection?.anchorNode,
85 | extentNode: selection?.focusNode,
86 | range,
87 | selectedHtml,
88 | selectedText,
89 | };
90 | return selectionDetails;
91 | }
92 |
93 | /* Is the target we are selecting within the `TargetElements`? */
94 | function isSelectionWithinTarget(
95 | targets: TargetElements,
96 | selection: SelectionDetails
97 | ): boolean {
98 | if (!targets?.length) return false;
99 | return Array.from(targets)?.some((t) =>
100 | selection?.baseNode ? t?.contains(selection.baseNode) : false
101 | );
102 | }
103 |
104 | export {
105 | setClipboard,
106 | getPopoverCoordinates,
107 | getSelectionDetails,
108 | getUISelection,
109 | getDomSelectionDetails,
110 | isSelectionWithinTarget,
111 | resolveTargets,
112 | };
113 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "Node",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 | "allowSyntheticDefaultImports": true,
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vite.config.pages.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | import { defineConfig } from "vite";
4 | import react from "@vitejs/plugin-react-swc";
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | base: "/react-highlight-menu/",
9 | plugins: [react()],
10 | test: {
11 | environment: "jsdom",
12 | globals: true,
13 | },
14 | build: {
15 | outDir: "./pages",
16 | },
17 | });
18 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | import { defineConfig } from "vite";
4 | import react from "@vitejs/plugin-react-swc";
5 | import path from "path";
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [react()],
10 | test: {
11 | environment: "jsdom",
12 | globals: true,
13 | },
14 |
15 | build: {
16 | outDir: "dist", // Specify the output directory
17 | lib: {
18 | entry: path.resolve(__dirname, "src/index.ts"),
19 | name: "ReactHighlightMenu",
20 | formats: ["es", "cjs", "umd", "iife"],
21 | fileName: (format) => `index.${format}.js`,
22 | },
23 | rollupOptions: {
24 | external: ["react", "react-dom", "react/jsx-runtime"],
25 | output: {
26 | globals: {
27 | "react-dom": "ReactDom",
28 | react: "React",
29 | "react/jsx-runtime": "ReactJsxRuntime",
30 | },
31 | },
32 | },
33 | },
34 | });
35 |
--------------------------------------------------------------------------------