Add a Custom Action to a Text Selection

Scenario
Section titled “Scenario”This example demonstrates how to add a custom action to a text selection in a React PDF Viewer component.
- Copy selected 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 @react-pdf-kit/viewer, custom selection 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 selected text) |
Selection.toString() | Extract the selected text content |
Selection.getRangeAt(0) | Retrieve the Range object for the selected text |
Range.getBoundingClientRect() | Calculate the screen position of the selected 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
-
Create a select dropdown component which will appear when a text is selected.
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 (<divclassName="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>);};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 (<divclassName="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>);}; -
Create an input component which also supports optional context (e.g. selected text to “ask”)
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 + enterif (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"><pref={inputRef}className="w-3/4"contentEditablesuppressContentEditableWarningonKeyDown={handleKeyDown}></p><buttononClick={handleSubmit}className="w-1/4 border-l border-gray-300">Submit</button></div></div>);};Input.tsx import { 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 + enterif (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"><pref={inputRef}className="w-3/4"contentEditablesuppressContentEditableWarningonKeyDown={handleKeyDown}></p><buttononClick={handleSubmit}className="w-1/4 border-l border-gray-300">Submit</button></div></div>);}; -
Create a chat component that uses the input component from step 2 and displays messages.
Chat.jsx import { 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 (<divclassName={ `lex ${type === "question" ? "justify-end" : "justify-start"} w-full`}><divclassName={`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 receivedcurrentMessageIndexRef.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"><divclassName="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"><Inputcontext={context}onSubmit={handleSubmit}onClearContext={onClearContext}ref={setInputRef}/></div></div>);};Chat.tsx import { 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 (<divclassName={ `lex ${type === "question" ? "justify-end" : "justify-start"} w-full`}><divclassName={ `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 receivedcurrentMessageIndexRef.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"><divclassName="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"><Inputcontext={context}onSubmit={handleSubmit}onClearContext={onClearContext}ref={setInputRef}/></div></div>);}; -
Combine the dropdown and chat components with the React PDF Viewer component
App.jsx import { Chat } from "./Chat";import { useCallback, useEffect, useState } from "react";import { SelectDropDown } from "./SelectDropDown";import {RPConfig,RPProvider,RPLayout,RPPages,} from "@react-pdf-kit/viewer";const App = () => {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 exitif (!selection || selection.rangeCount === 0) {setShowDropdown(false);return;}const selectedString = selection?.toString(); // Convert to stringconst selectedRange = selection?.getRangeAt(0); // Get the range object// Set selected text so we can use it latersetSelectedText(selectedString);// If the selection is empty or only whitespace, hide dropdownif (!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 selected 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 selected textsetMenuPosition({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"><RPLayout toolbar style={{ height: "600px" }}><RPPages /></RPLayout></RPProvider></div></RPConfig><SelectDropDownposition={menuPosition}show={showDropdown}onAsk={handleAsk}onCopy={handleCopy}/></div><div className="col-span-1"><Chatcontext={context}onClearContext={() => setContext(undefined)}/></div></div></>);};export default App;App.tsx import { Chat } from "./Chat";import { useCallback, useEffect, useState } from "react";import { SelectDropDown } from "./SelectDropDown";import {RPConfig,RPProvider,RPLayout,RPPages,} from "@react-pdf-kit/viewer";const App = () => {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 exitif (!selection || selection.rangeCount === 0) {setShowDropdown(false);return;}const selectedString = selection?.toString(); // Convert to stringconst selectedRange = selection?.getRangeAt(0); // Get the range object// Set selected text so we can use it latersetSelectedText(selectedString);// If the selection is empty or only whitespace, hide dropdownif (!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 selected 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 selected textsetMenuPosition({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"><RPLayout toolbar style={{ height: "600px" }}><RPPages /></RPLayout></RPProvider></div></RPConfig><SelectDropDownposition={menuPosition}show={showDropdown}onAsk={handleAsk}onCopy={handleCopy}/></div><div className="col-span-1"><Chatcontext={context}onClearContext={() => setContext(undefined)}/></div></div></>);};export default App;
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