Skip to content
Add a Custom Action to the Text Highlight Element

An image of Add a Custom Action to the Text Highlight Element

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

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().

NameObjective
window.getSelection()Return the current Selection object representing the user’s text selection
Selection.isCollapsedIndicate 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 / clientYCapture cursor position when selection finishes
onMouseUp eventDetect when the user completes a text selection
selectionchange eventListen for selection updates or clears
navigator.clipboard.writeText()Execute clipboard-based custom actions (optional)

Note This example uses react-markdown to 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.jsx
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>
);
};
// Chat.jsx
import { useCallback, useEffect, useRef, useState } from "react";
import { Input } from "./Input";
import Markdown from "react-markdown";
// message item for question and answer
const 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 interface
import { 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>
</>
);
};
Input.tsx
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>
);
};

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