Skip to content
Add a Custom Action to a Text Selection

An image of Add a Custom Action to a Text Selection

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

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

NameObjective
window.getSelection()Return the current Selection object representing the user’s text selection
Selection.isCollapsedIndicate 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 / 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

  1. 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 (
    <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>
    );
    };
  2. 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 + 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>
    );
    };
  3. 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 answer
    const Item = ({
    type,
    content,
    }) => {
    return (
    <div
    className={ ` lex ${
    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>
    );
    };
  4. 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 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 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 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">
    <RPLayout toolbar style={{ height: "600px" }}>
    <RPPages />
    </RPLayout>
    </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>
    </>
    );
    };
    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