Add a Custom Action to the Text Highlight Element

Scenario
Section titled “Scenario”This example demonstrates how to add a custom action to a text highlight in a React PDF viewer component. When a user selects text, a custom action menu appears near the highlight, allowing you to trigger any custom behavior (copy, annotate, comment, etc.).
- Copy highlighted text
- Add notes or comments
- Create references or bookmarks
- Trigger custom workflows on the selected content
What to Use
Section titled “What to Use”In addition to the core components from @pdf-viewer/react, custom highlight actions rely on standard browser selection APIs.
All highlight-related APIs come from the Selection object returned by window.getSelection().
| Name | Objective |
|---|---|
window.getSelection() | Return the current Selection object representing the user’s text selection |
Selection.isCollapsed | Indicate whether the selection is empty (no highlighted text) |
Selection.toString() | Extract the highlighted text content |
Selection.getRangeAt(0) | Retrieve the Range object for the highlighted text |
Range.getBoundingClientRect() | Calculate the screen position of the highlighted area |
MouseEvent.clientX / clientY | Capture cursor position when selection finishes |
onMouseUp event | Detect when the user completes a text selection |
selectionchange event | Listen for selection updates or clears |
navigator.clipboard.writeText() | Execute clipboard-based custom actions (optional) |
Note This example uses
react-markdownto render chat responses. Make sure it is installed before running the example.
To make the chat experience smooth, you’ll want these 3 components:
// SelectDropDown.jsx.import { useCallback } from "react";
export const SelectDropDown = ({ position, show, onAsk, onCopy,}) => { const handleAsk = useCallback(() => { onAsk(); }, [onAsk]); const handleCopy = useCallback(() => { onCopy(); }, [onCopy]);
if (!show) return null;
return ( <div className="absolute" style={{ top: `${position.y}px`, left: `${position.x}px` }} > <ul className="bg-white border border-gray-200 rounded-md p-2"> <li className="cursor-pointer hover:bg-gray-100" onClick={handleCopy}> Copy </li> <li className="cursor-pointer hover:bg-gray-100" onClick={handleAsk}> Explain </li> </ul> </div> );};
// Input.jsximport { useCallback, Ref, useRef } from "react";
export const Input = ({ ref, onSubmit, context, onClearContext,}) => { const inputRef = useRef(null);
const handleSubmit = useCallback(() => { const textValue = inputRef.current?.innerText; if (onSubmit && textValue) { onSubmit(textValue); inputRef.current!.innerText = ""; } }, [onSubmit]);
const handleKeyDown = useCallback( (e) => { // if press Enter only it will send question to server // allow new line by shift + enter if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }, [handleSubmit] );
return ( <div ref={ref} className="p-1"> {context && ( <div className="text-sm border-t border-gray-300 px-2"> <div className="flex justify-between"> Ask about:{" "} <span onClick={onClearContext} className="cursor-pointer"> X </span> </div> <div className="text-gray-500">{context}</div> </div> )} <div className="flex border-t border-gray-300"> <p ref={inputRef} className="w-3/4" contentEditable suppressContentEditableWarning onKeyDown={handleKeyDown} ></p> <button onClick={handleSubmit} className="w-1/4 border-l border-gray-300" > Submit </button> </div> </div> );};
// Chat.jsximport { useCallback, useEffect, useRef, useState } from "react";import { Input } from "./Input";import Markdown from "react-markdown";
// message item for question and answerconst Item = ({ type, content,}) => { return ( <div className={`flex ${ type === "question" ? "justify-end" : "justify-start" } w-full`} > <div className={`px-2 max-w-2/3 my-2 py-1 rounded ${ type === "question" ? "bg-neutral-200" : "bg-neutral-400" }`} > {/* render markdown content that will be received from ai */} <Markdown>{content}</Markdown> </div> </div> );};
export const Chat = ({ context, onClearContext }) => { const [inputRef, setInputRef] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const messageRef = useRef(""); const currentMessageIndexRef = useRef(0); const [conversationHeight, setConversationHeight] = useState("600px");
useEffect(() => { if (!inputRef) return; const observer = new ResizeObserver((entries) => { setConversationHeight(`calc(600px - ${entries[0].contentRect.height}px)`); }); observer.observe(inputRef); return () => { observer.disconnect(); }; }, [inputRef]);
const handleSubmit = useCallback( async (value) => { setLoading(true); messageRef.current = ""; setMessages((prev) => { // set message index to be appended when answer is received currentMessageIndexRef.current = prev.length + 1; return [ ...prev, { id: Date.now().toString(), type: "question", content: value }, ]; }); if (onClearContext) { onClearContext(); }
// Your backend endpoint }, [context, onClearContext] );
return ( <div className="h-150 relative"> <div className="overflow-y-auto" style={{ height: conversationHeight, }} > <div className="p-1"> {messages.map((ele) => ( <Item key={ele.id} type={ele.type} content={ele.content} /> ))} </div> </div> {/* avoid rerender when input height change due to new line */} <div className="absolute bottom-0 w-full"> <Input context={context} onSubmit={handleSubmit} onClearContext={onClearContext} ref={setInputRef} /> </div> </div> );};
//Combine PDF Viewer and Chat interfaceimport { Chat } from "./Chat";import { useCallback, useEffect, useState } from "react";import { SelectDropDown } from "./SelectDropDown";
export const PdfChat = () => { const [pdfViewer, setPdfViewer] = useState(); const [selectedText, setSelectedText] = useState(); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0, }); const [showDropdown, setShowDropdown] = useState(false); const [context, setContext] = useState();
const handleMouseUp = useCallback(() => { const selection = window.getSelection(); // Get current selection
// If there is no selection or no range, hide the dropdown and exit if (!selection || selection.rangeCount === 0) { setShowDropdown(false); return; }
const selectedString = selection?.toString(); // Convert to string const selectedRange = selection?.getRangeAt(0); // Get the range object
// Set selected text so we can use it later setSelectedText(selectedString);
// If the selection is empty or only whitespace, hide dropdown if (!selectedString || selectedString.trim().length === 0) { setShowDropdown(false); return; }
// A selection can span multiple lines. // getClientRects() returns a DOMRect for each visual line of the selection. const rects = selectedRange.getClientRects(); if (rects.length === 0) return;
// Use the LAST rectangle so the dropdown appears // at the end of the highlighted text (not the beginning) const lastRect = rects[rects.length - 1];
// Get the bounding rectangle of the PDF viewer container. // This allows us to convert from viewport coordinates // to container-relative coordinates. const containerRect = pdfViewer?.getBoundingClientRect();
if (containerRect) { // Position the dropdown relative to the PDF container // right edge + bottom edge of the highlighted text setMenuPosition({ x: lastRect.right - containerRect.left, y: lastRect.bottom - containerRect.top, }); setShowDropdown(true); } else { setShowDropdown(false); } }, []);
const handleAsk = useCallback(() => { setContext(selectedText); setShowDropdown(false); }, [selectedText]);
const handleCopy = useCallback(() => { if (selectedText) { window.navigator.clipboard.writeText(selectedText); } setShowDropdown(false); }, [selectedText]);
useEffect(() => { pdfViewer?.addEventListener("mouseup", handleMouseUp);
return () => { pdfViewer?.removeEventListener("mouseup", handleMouseUp); }; }, [handleMouseUp, pdfViewer]);
return ( <> <div className="grid grid-cols-3 gap-2"> <div className="col-span-2 relative"> <RPConfig licenseKey="YOUR_LICENSE_KEY"> <div ref={setPdfViewer}> <RPProvider src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"> <RPDefaultLayout style={{ height: "600px" }}> <RPPages /> </RPDefaultLayout> </RPProvider> </div> </RPConfig> <SelectDropDown position={menuPosition} show={showDropdown} onAsk={handleAsk} onCopy={handleCopy} /> </div> <div className="col-span-1"> <Chat context={context} onClearContext={() => setContext(undefined)} /> </div> </div> </> );};import { useCallback, Ref, useRef } from "react";
export const Input = ({ ref, onSubmit, context, onClearContext,}) => { const inputRef = useRef(null);
const handleSubmit = useCallback(() => { const textValue = inputRef.current?.innerText; if (onSubmit && textValue) { onSubmit(textValue); inputRef.current!.innerText = ""; } }, [onSubmit]);
const handleKeyDown = useCallback( (e) => { // if press Enter only it will send question to server // allow new line by shift + enter if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }, [handleSubmit] );
return ( <div ref={ref} className="p-1"> {context && ( <div className="text-sm border-t border-gray-300 px-2"> <div className="flex justify-between"> Ask about:{" "} <span onClick={onClearContext} className="cursor-pointer"> X </span> </div> <div className="text-gray-500">{context}</div> </div> )} <div className="flex border-t border-gray-300"> <p ref={inputRef} className="w-3/4" contentEditable suppressContentEditableWarning onKeyDown={handleKeyDown} ></p> <button onClick={handleSubmit} className="w-1/4 border-l border-gray-300" > Submit </button> </div> </div> );};// SelectDropDown.tsx.import { useCallback } from "react";
interface SelectDropDownProps { position: { x: number; y: number; }; show: boolean; onAsk: () => void; onCopy: () => void;}
export const SelectDropDown = ({ position, show, onAsk, onCopy,}: SelectDropDownProps) => { const handleAsk = useCallback(() => { onAsk(); }, [onAsk]); const handleCopy = useCallback(() => { onCopy(); }, [onCopy]);
if (!show) return null;
return ( <div className="absolute" style={{ top: `${position.y}px`, left: `${position.x}px` }} > <ul className="bg-white border border-gray-200 rounded-md p-2"> <li className="cursor-pointer hover:bg-gray-100" onClick={handleCopy}> Copy </li> <li className="cursor-pointer hover:bg-gray-100" onClick={handleAsk}> Explain </li> </ul> </div> );};
// Input.tsximport { useCallback, Ref, useRef } from "react";
interface InputProps { onSubmit?: (value: string) => void; onClearContext?: () => void; ref?: Ref<HTMLDivElement>; context?: string;}
export const Input = ({ ref, onSubmit, context, onClearContext,}: InputProps) => { const inputRef = useRef<HTMLDivElement>(null);
const handleSubmit = useCallback(() => { const textValue = inputRef.current?.innerText; if (onSubmit && textValue) { onSubmit(textValue); inputRef.current!.innerText = ""; } }, [onSubmit]);
const handleKeyDown = useCallback( (e: React.KeyboardEvent<HTMLInputElement>) => { // if press Enter only it will send question to server // allow new line by shift + enter if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }, [handleSubmit] );
return ( <div ref={ref} className="p-1"> {context && ( <div className="text-sm border-t border-gray-300 px-2"> <div className="flex justify-between"> Ask about:{" "} <span onClick={onClearContext} className="cursor-pointer"> X </span> </div> <div className="text-gray-500">{context}</div> </div> )} <div className="flex border-t border-gray-300"> <p ref={inputRef} className="w-3/4" contentEditable suppressContentEditableWarning onKeyDown={handleKeyDown} ></p> <button onClick={handleSubmit} className="w-1/4 border-l border-gray-300" > Submit </button> </div> </div> );};
// Chat.tsximport { useCallback, useEffect, useRef, useState } from "react";import { Input } from "./Input";import Markdown from "react-markdown";
// message item for question and answerconst Item = ({ type, content,}: { type: "question" | "answer"; content: string;}) => { return ( <div className={`flex ${ type === "question" ? "justify-end" : "justify-start" } w-full`} > <div className={`px-2 max-w-2/3 my-2 py-1 rounded ${ type === "question" ? "bg-neutral-200" : "bg-neutral-400" }`} > {/* render markdown content that will be received from ai */} <Markdown>{content}</Markdown> </div> </div> );};
interface ChatProps { context?: string; onClearContext?: () => void;}
export const Chat = ({ context, onClearContext }: ChatProps) => { const [inputRef, setInputRef] = useState<HTMLDivElement | null>(null); const [messages, setMessages] = useState< { id: string; type: "question" | "answer"; content: string }[] >([]); const [loading, setLoading] = useState(false); const messageRef = useRef<string>(""); const currentMessageIndexRef = useRef<number>(0); const [conversationHeight, setConversationHeight] = useState<string>("600px");
useEffect(() => { if (!inputRef) return; const observer = new ResizeObserver((entries) => { setConversationHeight(`calc(600px - ${entries[0].contentRect.height}px)`); }); observer.observe(inputRef); return () => { observer.disconnect(); }; }, [inputRef]);
const handleSubmit = useCallback( async (value: string) => { setLoading(true); messageRef.current = ""; setMessages((prev) => { // set message index to be appended when answer is received currentMessageIndexRef.current = prev.length + 1; return [ ...prev, { id: Date.now().toString(), type: "question", content: value }, ]; }); if (onClearContext) { onClearContext(); }
// Your backend endpoint }, [context, onClearContext] );
return ( <div className="h-150 relative"> <div className="overflow-y-auto" style={{ height: conversationHeight, }} > <div className="p-1"> {messages.map((ele) => ( <Item key={ele.id} type={ele.type} content={ele.content} /> ))} </div> </div> {/* avoid rerender when input height change due to new line */} <div className="absolute bottom-0 w-full"> <Input context={context} onSubmit={handleSubmit} onClearContext={onClearContext} ref={setInputRef} /> </div> </div> );};
//Combine PDF Viewer and Chat interfaceimport { Chat } from "./Chat";import { useCallback, useEffect, useState } from "react";import { SelectDropDown } from "./SelectDropDown";
export const PdfChat = () => { const [pdfViewer, setPdfViewer] = useState<HTMLDivElement | null>(); const [selectedText, setSelectedText] = useState<string>(); const [menuPosition, setMenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0, }); const [showDropdown, setShowDropdown] = useState<boolean>(false); const [context, setContext] = useState<string>();
const handleMouseUp = useCallback(() => { const selection = window.getSelection(); // Get current selection
// If there is no selection or no range, hide the dropdown and exit if (!selection || selection.rangeCount === 0) { setShowDropdown(false); return; }
const selectedString = selection?.toString(); // Convert to string const selectedRange = selection?.getRangeAt(0); // Get the range object
// Set selected text so we can use it later setSelectedText(selectedString);
// If the selection is empty or only whitespace, hide dropdown if (!selectedString || selectedString.trim().length === 0) { setShowDropdown(false); return; }
// A selection can span multiple lines. // getClientRects() returns a DOMRect for each visual line of the selection. const rects = selectedRange.getClientRects(); if (rects.length === 0) return;
// Use the LAST rectangle so the dropdown appears // at the end of the highlighted text (not the beginning) const lastRect = rects[rects.length - 1];
// Get the bounding rectangle of the PDF viewer container. // This allows us to convert from viewport coordinates // to container-relative coordinates. const containerRect = pdfViewer?.getBoundingClientRect();
if (containerRect) { // Position the dropdown relative to the PDF container // right edge + bottom edge of the highlighted text setMenuPosition({ x: lastRect.right - containerRect.left, y: lastRect.bottom - containerRect.top, }); setShowDropdown(true); } else { setShowDropdown(false); } }, []);
const handleAsk = useCallback(() => { setContext(selectedText); setShowDropdown(false); }, [selectedText]);
const handleCopy = useCallback(() => { if (selectedText) { window.navigator.clipboard.writeText(selectedText); } setShowDropdown(false); }, [selectedText]);
useEffect(() => { pdfViewer?.addEventListener("mouseup", handleMouseUp);
return () => { pdfViewer?.removeEventListener("mouseup", handleMouseUp); }; }, [handleMouseUp, pdfViewer]);
return ( <> <div className="grid grid-cols-3 gap-2"> <div className="col-span-2 relative"> <RPConfig licenseKey="YOUR_LICENSE_KEY"> <div ref={setPdfViewer}> <RPProvider src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"> <RPDefaultLayout style={{ height: "600px" }}> <RPPages /> </RPDefaultLayout> </RPProvider> </div> </RPConfig> <SelectDropDown position={menuPosition} show={showDropdown} onAsk={handleAsk} onCopy={handleCopy} /> </div> <div className="col-span-1"> <Chat context={context} onClearContext={() => setContext(undefined)} /> </div> </div> </> );};Notes:
- The text selection is retrieved using
window.getSelection()and ignored if no valid range exists - Selections that contain only spaces or line breaks are ignored so the action menu doesn’t appear by mistake
- Use
Range.getClientRects()to support selections that span multiple visual lines - The last client rect is used so the dropdown appears at the end of the highlighted text
- Menu coordinates are calculated relative to the PDF viewer container, not the viewport
- The dropdown is shown or hidden automatically based on selection validity