Skip to content

Selecting and Outputting Text Selected



For certain applications that require PDF interaction, you want to provide some options for your users' convenience. A more recent example is when a user select a text to ask a question in an PDF chat using AI with LLM models.

Vue PDF Viewer supports additional actions when selecting text, allowing for an interactive experience with PDF pages.

This tutorial will guide you through how to show a popover of options (i.e. Ask and Copy) after selecting text on a PDF page.

Here is a quick overview of what you’ll learn in this tutorial:

  • Detect and capture selected text inside a PDF viewer
  • Show a floating popover menu near the selection
  • Provide actions like Ask and Copy for the selected text

Components Overview

1. VPdfViewer

  • The core PDF viewer component from @vue-pdf-viewer/viewer.
  • This renders the PDF content and allows interaction such as text selection inside a PDF page.

2. Popover Menu

  • A small floating popover showing actions like Ask and Copy when text is selected.
  • The popover menu appears next to the selected text using dynamic positioning for user-friendly experience.

Tutorial for Selecting and Outputting Text Selected

Code Explanation

Script

  • When the component is mounted, it sets up an observer to wait until the PDF content is ready.
  • A mouseup event listener is added to detect when a user selects some text inside the PDF.
  • If text is selected, it calculates the position and shows a popover menu nearby.
  • The popover menu provides two actions, namely Ask the selected text or Copy the selection.
vue
<script lang="ts" setup>
// Import the VPdfViewer component from vue-pdf-viewer
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
// Import Vue composition functions
import { onMounted, ref } from "vue";

// Reference to the PDF viewer component instance
const vpvRef = ref<InstanceType<typeof VPdfViewer>>();
// Boolean to control the visibility of the dropdown menu
const showDropdown = ref(false);
// Store the position of the dropdown menu
const menuPosition = ref({ x: 0, y: 0 });
// Store the selected text from the PDF
const selectedText = ref<string>();
// Store the selected ask text from the PDF
const selectedAskText = ref<string>();

// Function to copy the selected text to the clipboard
const handleCopy = () => {
  if (selectedText.value) {
    // Use Clipboard API to write selected text
    navigator.clipboard.writeText(selectedText.value);
  }
  // Clear selection and hide dropdown
  clearSelection();
};

// Function to ask the selected text
const handleAsk = () => {
  if (selectedText.value) {
    // Set into selected ask text value
    selectedAskText.value = selectedText.value;
  }
  // Clear selection and hide dropdown
  clearSelection();
};

// Function to clear current selection and hide dropdown
const clearSelection = () => {
  // Remove text selection from the window
  window.getSelection()?.removeAllRanges();
  showDropdown.value = false;
  selectedText.value = undefined;
};

// Run when component is mounted
onMounted(() => {
  // Create a mutation observer to wait until PDF content is loaded
  const observer = new MutationObserver(() => {
    const element = vpvRef.value?.$el; // Get the element of the PDF viewer
    if (!element) {
      return;
    }
    // Add mouseup event listener to detect text selection
    element.addEventListener("mouseup", () => {
      vpvRef.value?.$el.addEventListener("mouseup", () => {
        const selection = window.getSelection(); // Get current selection
        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
        selectedText.value = selectedString;

        // If there's valid selection, show dropdown
        if (
          selectedString &&
          selectedString.trim().length > 0 &&
          selectedRange
        ) {
          const rangeBounds = selectedRange.getBoundingClientRect();
          // Position the dropdown near the selection
          menuPosition.value = {
            x: rangeBounds.left + window.scrollX,
            y: rangeBounds.bottom + window.scrollY,
          };
          showDropdown.value = true;
        } else {
          showDropdown.value = false;
        }
      });
    });
    // Stop observing once setup is complete
    observer.disconnect();
  });

  // Start observing changes to the body (useful for dynamic loading)
  observer.observe(document.body, { childList: true, subtree: true });
});
</script>
vue
<script setup>
// Import the VPdfViewer component from vue-pdf-viewer
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
// Import Vue composition functions
import { onMounted, ref } from "vue";

// Reference to the PDF viewer component instance
const vpvRef = ref();
// Boolean to control the visibility of the dropdown menu
const showDropdown = ref(false);
// Store the position of the dropdown menu
const menuPosition = ref({ x: 0, y: 0 });
// Store the selected text from the PDF
const selectedText = ref();
// Store the selected ask text from the PDF
const selectedAskText = ref();

// Function to copy the selected text to the clipboard
const handleCopy = () => {
  if (selectedText.value) {
    // Use Clipboard API to write selected text
    navigator.clipboard.writeText(selectedText.value);
  }
  // Clear selection and hide dropdown
  clearSelection();
};

// Function to ask the selected text
const handleAsk = () => {
  if (selectedText.value) {
    // Set into selected ask text value
    selectedAskText.value = selectedText.value;
  }
  // Clear selection and hide dropdown
  clearSelection();
};

// Function to clear current selection and hide dropdown
const clearSelection = () => {
  // Remove text selection from the window
  window.getSelection()?.removeAllRanges();
  showDropdown.value = false;
  selectedText.value = undefined;
};

// Run when component is mounted
onMounted(() => {
  // Create a mutation observer to wait until PDF content is loaded
  const observer = new MutationObserver(() => {
    const element = vpvRef.value?.$el;
    if (!element) return;
    // Add mouseup event listener to detect text selection
    element.addEventListener("mouseup", () => {
      const selection = window.getSelection(); // Get current selection
      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
      selectedText.value = selectedString;

      // If there's valid selection, show dropdown
      if (selectedString && selectedString.trim().length > 0 && selectedRange) {
        const rangeBounds = selectedRange.getBoundingClientRect();
        // Position the dropdown near the selection
        menuPosition.value = {
          x: rangeBounds.left + window.scrollX,
          y: rangeBounds.bottom + window.scrollY,
        };
        showDropdown.value = true;
      } else {
        showDropdown.value = false;
      }
    });
    // Stop observing once setup is complete
    observer.disconnect();
  });
  // Start observing changes to the body (useful for dynamic loading)
  observer.observe(document.body, { childList: true, subtree: true });
});
</script>
vue
<script lang="ts">
// Import the VPdfViewer component from vue-pdf-viewer
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
// Import Vue composition functions
import { defineComponent } from "vue";

export default defineComponent({
  components: { VPdfViewer },
  data() {
    return {
      showDropdown: false,
      menuPosition: { x: 0, y: 0 },
      selectedText: undefined as string | undefined,
      selectedAskText: undefined as string | undefined,
    };
  },
  // Run when component is mounted
  mounted() {
    // Create a mutation observer to wait until PDF content is loaded
    const observer = new MutationObserver(() => {
      const element = (this.$refs.vpvRef as any)?.$el;
      if (!element) return;
      // Add mouseup event listener to detect text selection
      element.addEventListener("mouseup", () => {
        const selection = window.getSelection(); // Get current selection
        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
        this.selectedText = selectedString;

        // If there's valid selection, show dropdown
        if (
          selectedString &&
          selectedString.trim().length > 0 &&
          selectedRange
        ) {
          const rangeBounds = selectedRange.getBoundingClientRect();
          // Position the dropdown near the selection
          this.menuPosition = {
            x: rangeBounds.left + window.scrollX,
            y: rangeBounds.bottom + window.scrollY,
          };
          this.showDropdown = true;
        } else {
          this.showDropdown = false;
        }
      });

      // Stop observing once setup is complete
      observer.disconnect();
    });
    // Start observing changes to the body (useful for dynamic loading)
    observer.observe(document.body, { childList: true, subtree: true });
  },
  methods: {
    // Function to copy the selected text to the clipboard
    handleCopy() {
      if (this.selectedText) {
        // Use Clipboard API to write selected text
        navigator.clipboard.writeText(this.selectedText);
      }
      // Clear selection and hide dropdown
      this.clearSelection();
    },
    // Function to ask the selected text
    handleAsk() {
      if (this.selectedText) {
        // Set into selected ask text value
        this.selectedAskText = this.selectedText;
      }
      // Clear selection and hide dropdown
      this.clearSelection();
    },
    // Function to clear current selection and hide dropdown
    clearSelection() {
      // Remove text selection from the window
      window.getSelection()?.removeAllRanges();
      this.showDropdown = false;
      this.selectedText = undefined;
    },
  },
});
</script>
vue
<script>
// Import the VPdfViewer component from vue-pdf-viewer
import { VPdfViewer } from "@vue-pdf-viewer/viewer";

export default {
  components: { VPdfViewer },
  data() {
    return {
      vpvRef: null,
      showDropdown: false,
      menuPosition: { x: 0, y: 0 },
      selectedText: undefined,
      selectedAskText: undefined,
    };
  },
  // Run when component is mounted
  mounted() {
    // Create a mutation observer to wait until PDF content is loaded
    const observer = new MutationObserver(() => {
      const element = this.$refs.vpvRef?.$el;
      if (!element) return;
      // Add mouseup event listener to detect text selection
      element.addEventListener("mouseup", () => {
        const selection = window.getSelection(); // Get current selection
        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
        this.selectedText = selectedString;

        // If there's valid selection, show dropdown
        if (
          selectedString &&
          selectedString.trim().length > 0 &&
          selectedRange
        ) {
          const rangeBounds = selectedRange.getBoundingClientRect();
          // Position the dropdown near the selection
          this.menuPosition = {
            x: rangeBounds.left + window.scrollX,
            y: rangeBounds.bottom + window.scrollY,
          };
          this.showDropdown = true;
        } else {
          this.showDropdown = false;
        }
      });

      // Stop observing once setup is complete
      observer.disconnect();
    });
    // Start observing changes to the body (useful for dynamic loading)
    observer.observe(document.body, { childList: true, subtree: true });
  },
  methods: {
    // Function to copy the selected text to the clipboard
    handleCopy() {
      if (this.selectedText) {
        // Use Clipboard API to write selected text
        navigator.clipboard.writeText(this.selectedText);
      }
      // Clear selection and hide dropdown
      this.clearSelection();
    },
    // Function to ask the selected text
    handleAsk() {
      if (this.selectedText) {
        // Set into selected ask text value
        this.selectedAskText = this.selectedText;
      }
      // Clear selection and hide dropdown
      this.clearSelection();
    },
    // Function to clear current selection and hide dropdown
    clearSelection() {
      // Remove text selection from the window
      window.getSelection()?.removeAllRanges();
      this.showDropdown = false;
      this.selectedText = undefined;
    },
  },
};
</script>

Template

  • Renders the VPdfViewer component inside a styled container.
  • Shows the popover menu dynamically if text is selected.
  • Positions the menu based on where the selection occurs.
  • Shows the Ask card of the selected text
vue
<template>
  <!-- Main container -->
  <div :style="{ display: 'flex', padding: '8px' }">
    <!-- Left panel: container for the PDF viewer -->
    <div :style="{ width: '1028px', height: '700px', position: 'relative' }">
      <!-- Render the PDF using VPdfViewer -->
      <VPdfViewer
        ref="vpvRef"
        src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
      />
    </div>

    <!-- Right panel: card UI for showing selected asked text -->
    <div class="card">
      <!-- Card header -->
      <div class="card-header">
        Ask (after highlight a text and click "Ask")
      </div>
      <!-- Divider line between header and content -->
      <div class="card-divider" />
      <!-- Card content showing the selected text that will be asked -->
      <div class="card-content">
        <!-- Display the selected text from the PDF -->
        {{ selectedAskText }}
      </div>
    </div>
  </div>

  <!-- Floating Dropdown Menu when text is selected -->
  <div
    v-if="showDropdown"
    class="dropdown-menu"
    :style="{ top: `${menuPosition.y}px`, left: `${menuPosition.x}px` }"
  >
    <ul>
      <!-- Option to ask the selected text -->
      <li @click="handleAsk">Ask</li>
      <!-- Option to copy the selected text -->
      <li @click="handleCopy">Copy</li>
    </ul>
  </div>
</template>

Styles

  • Style the card and the popover menu to look clean and simple
  • Add a hover effect to make the options visually interactive
css
<style lang="css" scoped>
/* Card container */
.card {
  width: 360px;
  height: fit-content;
  background-color: #fff;
  border-radius: 8px;
  margin-left: 12px;
  font-family: sans-serif;
  border: 1px solid rgba(0, 0, 0, 0.1);
}

/* Header section of the card */
.card-header {
  padding: 16px;
  font-weight: bold;
}

/* Divider line between card sections */
.card-divider {
  height: 1px;
  background-color: #e5e7eb;
}

/* Scrollable content area inside the card */
.card-content {
  height: 600px;
  padding: 16px;
  line-height: 1.5;
  overflow: auto;
}

/* Container for the dropdown menu */
.dropdown-menu {
  position: absolute;
  background-color: #fff;
  border: 1px solid #ccc;
  z-index: 100;
}

/* Horizontal list inside the dropdown menu */
.dropdown-menu ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
}

/* Hover state for dropdown items */
.dropdown-menu li:hover {
  background-color: #efefef;
}

/* Individual dropdown menu item */
.dropdown-menu li {
  padding: 8px 12px;
  cursor: pointer;
  color: #000;
  text-align: center;
  white-space: nowrap;
}

/* Divider between dropdown items */
.dropdown-menu li + li {
  border-left: 1px solid #ccc;
}
</style>

Complete example

Here’s how everything fits together:

  1. The VPdfViewer loads and displays the PDF.
  2. When a user selects some text on a PDF page, a popover menu appears.
  3. The popover menu gives the user options to ask or copy the selection.

Here is a complete example of how you can display options after selection some text on Vue PDF Viewer.

vue
<script lang="ts" setup>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
import { onMounted, ref } from "vue";

const vpvRef = ref<InstanceType<typeof VPdfViewer>>();
const showDropdown = ref(false);
const menuPosition = ref({ x: 0, y: 0 });
const selectedText = ref<string>();
const selectedAskText = ref<string>();

const handleCopy = () => {
  if (selectedText.value) {
    navigator.clipboard.writeText(selectedText.value);
  }
  clearSelection();
};

const handleAsk = () => {
  if (selectedText.value) {
    selectedAskText.value = selectedText.value;
  }
  clearSelection();
};

const clearSelection = () => {
  window.getSelection()?.removeAllRanges();
  showDropdown.value = false;
  selectedText.value = undefined;
};

onMounted(() => {
  const observer = new MutationObserver(() => {
    const element = vpvRef.value?.$el;
    if (!element) {
      return;
    }
    element.addEventListener("mouseup", () => {
      vpvRef.value?.$el.addEventListener("mouseup", () => {
        const selection = window.getSelection();
        const selectedString = selection?.toString();
        const selectedRange = selection?.getRangeAt(0);

        selectedText.value = selectedString;

        if (
          selectedString &&
          selectedString.trim().length > 0 &&
          selectedRange
        ) {
          const rangeBounds = selectedRange.getBoundingClientRect();
          menuPosition.value = {
            x: rangeBounds.left + window.scrollX,
            y: rangeBounds.bottom + window.scrollY,
          };
          showDropdown.value = true;
        } else {
          showDropdown.value = false;
        }
      });
    });
    observer.disconnect();
  });

  observer.observe(document.body, { childList: true, subtree: true });
});
</script>

<template>
  <div :style="{ display: 'flex', padding: '8px' }">
    <div :style="{ width: '1028px', height: '700px', position: 'relative' }">
      <VPdfViewer
        ref="vpvRef"
        src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
      />
    </div>

    <div class="card">
      <div class="card-header">
        Ask (after highlight a text and click "Ask")
      </div>
      <div class="card-divider" />
      <div class="card-content">
        {{ selectedAskText }}
      </div>
    </div>
  </div>

  <div
    v-if="showDropdown"
    class="dropdown-menu"
    :style="{ top: `${menuPosition.y}px`, left: `${menuPosition.x}px` }"
  >
    <ul>
      <li @click="handleAsk">Ask</li>
      <li @click="handleCopy">Copy</li>
    </ul>
  </div>
</template>

<style lang="css" scoped>
.card {
  width: 360px;
  height: fit-content;
  background-color: #fff;
  border-radius: 8px;
  margin-left: 12px;
  font-family: sans-serif;
  border: 1px solid rgba(0, 0, 0, 0.1);
}

.card-header {
  padding: 16px;
  font-weight: bold;
}

.card-divider {
  height: 1px;
  background-color: #e5e7eb;
}

.card-content {
  height: 600px;
  padding: 16px;
  line-height: 1.5;
  overflow: auto;
}

.dropdown-menu {
  position: absolute;
  background-color: #fff;
  border: 1px solid #ccc;
  z-index: 100;
}

.dropdown-menu ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
}

.dropdown-menu li:hover {
  background-color: #efefef;
}

.dropdown-menu li {
  padding: 8px 12px;
  cursor: pointer;
  color: #000;
  text-align: center;
  white-space: nowrap;
}

.dropdown-menu li + li {
  border-left: 1px solid #ccc;
}
</style>
vue
<script>
import { defineComponent, onMounted, ref } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";

export default defineComponent({
  components: { VPdfViewer },
  setup() {
    const vpvRef = ref();
    const showDropdown = ref(false);
    const menuPosition = ref({ x: 0, y: 0 });
    const selectedText = ref();
    const selectedAskText = ref();

    const handleCopy = () => {
      if (selectedText.value) {
        navigator.clipboard.writeText(selectedText.value);
      }
      clearSelection();
    };

    const handleAsk = () => {
      if (selectedText.value) {
        selectedAskText.value = selectedText.value;
      }
      clearSelection();
    };

    const clearSelection = () => {
      window.getSelection()?.removeAllRanges();
      showDropdown.value = false;
      selectedText.value = undefined;
    };

    onMounted(() => {
      const observer = new MutationObserver(() => {
        const element = vpvRef.value?.$el;
        if (!element) return;
        element.addEventListener("mouseup", () => {
          const selection = window.getSelection();
          const selectedString = selection?.toString();
          const selectedRange = selection?.getRangeAt(0);
          selectedText.value = selectedString;

          if (
            selectedString &&
            selectedString.trim().length > 0 &&
            selectedRange
          ) {
            const rangeBounds = selectedRange.getBoundingClientRect();
            menuPosition.value = {
              x: rangeBounds.left + window.scrollX,
              y: rangeBounds.bottom + window.scrollY,
            };
            showDropdown.value = true;
          } else {
            showDropdown.value = false;
          }
        });
        observer.disconnect();
      });
      observer.observe(document.body, { childList: true, subtree: true });
    });

    return {
      vpvRef,
      showDropdown,
      menuPosition,
      selectedText,
      selectedAskText,
      handleCopy,
      handleAsk,
    };
  },
});
</script>

<template>
  <div :style="{ display: 'flex', padding: '8px' }">
    <div :style="{ width: '1028px', height: '700px', position: 'relative' }">
      <VPdfViewer
        ref="vpvRef"
        src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
      />
    </div>

    <div class="card">
      <div class="card-header">
        Ask (after highlight a text and click "Ask")
      </div>
      <div class="card-divider" />
      <div class="card-content">
        {{ selectedAskText }}
      </div>
    </div>
  </div>

  <div
    v-if="showDropdown"
    class="dropdown-menu"
    :style="{ top: `${menuPosition.y}px`, left: `${menuPosition.x}px` }"
  >
    <ul>
      <li @click="handleAsk">Ask</li>
      <li @click="handleCopy">Copy</li>
    </ul>
  </div>
</template>

<style lang="css" scoped>
.card {
  width: 360px;
  height: fit-content;
  background-color: #fff;
  border-radius: 8px;
  margin-left: 12px;
  font-family: sans-serif;
  border: 1px solid rgba(0, 0, 0, 0.1);
}

.card-header {
  padding: 16px;
  font-weight: bold;
}

.card-divider {
  height: 1px;
  background-color: #e5e7eb;
}

.card-content {
  height: 600px;
  padding: 16px;
  line-height: 1.5;
  overflow: auto;
}

.dropdown-menu {
  position: absolute;
  background-color: #fff;
  border: 1px solid #ccc;
  z-index: 100;
}

.dropdown-menu ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
}

.dropdown-menu li:hover {
  background-color: #efefef;
}

.dropdown-menu li {
  padding: 8px 12px;
  cursor: pointer;
  color: #000;
  text-align: center;
  white-space: nowrap;
}

.dropdown-menu li + li {
  border-left: 1px solid #ccc;
}
</style>
vue
<script lang="ts">
import { defineComponent } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";

export default defineComponent({
  components: { VPdfViewer },
  data() {
    return {
      vpvRef: null as InstanceType<typeof VPdfViewer> | null,
      showDropdown: false,
      menuPosition: { x: 0, y: 0 },
      selectedText: undefined as string | undefined,
      selectedAskText: undefined as string | undefined,
    };
  },
  methods: {
    handleCopy() {
      if (this.selectedText) {
        navigator.clipboard.writeText(this.selectedText);
      }
      this.clearSelection();
    },
    handleAsk() {
      if (this.selectedText) {
        this.selectedAskText = this.selectedText;
      }
      this.clearSelection();
    },
    clearSelection() {
      window.getSelection()?.removeAllRanges();
      this.showDropdown = false;
      this.selectedText = undefined;
    },
  },
  mounted() {
    const observer = new MutationObserver(() => {
      const element = (this.$refs.vpvRef as any)?.$el;
      if (!element) return;
      element.addEventListener("mouseup", () => {
        const selection = window.getSelection();
        const selectedString = selection?.toString();
        const selectedRange = selection?.getRangeAt(0);
        this.selectedText = selectedString;

        if (
          selectedString &&
          selectedString.trim().length > 0 &&
          selectedRange
        ) {
          const rangeBounds = selectedRange.getBoundingClientRect();
          this.menuPosition = {
            x: rangeBounds.left + window.scrollX,
            y: rangeBounds.bottom + window.scrollY,
          };
          this.showDropdown = true;
        } else {
          this.showDropdown = false;
        }
      });
      observer.disconnect();
    });

    observer.observe(document.body, { childList: true, subtree: true });
  },
});
</script>

<template>
  <div :style="{ display: 'flex', padding: '8px' }">
    <div :style="{ width: '1028px', height: '700px', position: 'relative' }">
      <VPdfViewer
        ref="vpvRef"
        src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
      />
    </div>

    <div class="card">
      <div class="card-header">
        Ask (after highlight a text and click "Ask")
      </div>
      <div class="card-divider" />
      <div class="card-content">
        {{ selectedAskText }}
      </div>
    </div>
  </div>

  <div
    v-if="showDropdown"
    class="dropdown-menu"
    :style="{ top: `${menuPosition.y}px`, left: `${menuPosition.x}px` }"
  >
    <ul>
      <li @click="handleAsk">Ask</li>
      <li @click="handleCopy">Copy</li>
    </ul>
  </div>
</template>

<style lang="css" scoped>
.card {
  width: 360px;
  height: fit-content;
  background-color: #fff;
  border-radius: 8px;
  margin-left: 12px;
  font-family: sans-serif;
  border: 1px solid rgba(0, 0, 0, 0.1);
}

.card-header {
  padding: 16px;
  font-weight: bold;
}

.card-divider {
  height: 1px;
  background-color: #e5e7eb;
}

.card-content {
  height: 600px;
  padding: 16px;
  line-height: 1.5;
  overflow: auto;
}

.dropdown-menu {
  position: absolute;
  background-color: #fff;
  border: 1px solid #ccc;
  z-index: 100;
}

.dropdown-menu ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
}

.dropdown-menu li:hover {
  background-color: #efefef;
}

.dropdown-menu li {
  padding: 8px 12px;
  cursor: pointer;
  color: #000;
  text-align: center;
  white-space: nowrap;
}

.dropdown-menu li + li {
  border-left: 1px solid #ccc;
}
</style>
vue
<script>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";

export default {
  components: { VPdfViewer },
  data() {
    return {
      vpvRef: null,
      showDropdown: false,
      menuPosition: { x: 0, y: 0 },
      selectedText: undefined,
      selectedAskText: undefined,
    };
  },
  methods: {
    handleCopy() {
      if (this.selectedText) {
        navigator.clipboard.writeText(this.selectedText);
      }
      this.clearSelection();
    },
    handleAsk() {
      if (this.selectedText) {
        this.selectedAskText = this.selectedText;
      }
      this.clearSelection();
    },
    clearSelection() {
      window.getSelection()?.removeAllRanges();
      this.showDropdown = false;
      this.selectedText = undefined;
    },
  },
  mounted() {
    const observer = new MutationObserver(() => {
      const element = this.$refs.vpvRef?.$el;
      if (!element) return;
      element.addEventListener("mouseup", () => {
        const selection = window.getSelection();
        const selectedString = selection?.toString();
        const selectedRange = selection?.getRangeAt(0);
        this.selectedText = selectedString;

        if (
          selectedString &&
          selectedString.trim().length > 0 &&
          selectedRange
        ) {
          const rangeBounds = selectedRange.getBoundingClientRect();
          this.menuPosition = {
            x: rangeBounds.left + window.scrollX,
            y: rangeBounds.bottom + window.scrollY,
          };
          this.showDropdown = true;
        } else {
          this.showDropdown = false;
        }
      });
      observer.disconnect();
    });

    observer.observe(document.body, { childList: true, subtree: true });
  },
};
</script>

<template>
  <div :style="{ display: 'flex', padding: '8px' }">
    <div :style="{ width: '1028px', height: '700px', position: 'relative' }">
      <VPdfViewer
        ref="vpvRef"
        src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
      />
    </div>

    <div class="card">
      <div class="card-header">
        Ask (after highlight a text and click "Ask")
      </div>
      <div class="card-divider" />
      <div class="card-content">
        {{ selectedAskText }}
      </div>
    </div>
  </div>

  <div
    v-if="showDropdown"
    class="dropdown-menu"
    :style="{ top: `${menuPosition.y}px`, left: `${menuPosition.x}px` }"
  >
    <ul>
      <li @click="handleAsk">Ask</li>
      <li @click="handleCopy">Copy</li>
    </ul>
  </div>
</template>

<style lang="css" scoped>
.card {
  width: 360px;
  height: fit-content;
  background-color: #fff;
  border-radius: 8px;
  margin-left: 12px;
  font-family: sans-serif;
  border: 1px solid rgba(0, 0, 0, 0.1);
}

.card-header {
  padding: 16px;
  font-weight: bold;
}

.card-divider {
  height: 1px;
  background-color: #e5e7eb;
}

.card-content {
  height: 600px;
  padding: 16px;
  line-height: 1.5;
  overflow: auto;
}

.dropdown-menu {
  position: absolute;
  background-color: #fff;
  border: 1px solid #ccc;
  z-index: 100;
}

.dropdown-menu ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
}

.dropdown-menu li:hover {
  background-color: #efefef;
}

.dropdown-menu li {
  padding: 8px 12px;
  cursor: pointer;
  color: #000;
  text-align: center;
  white-space: nowrap;
}

.dropdown-menu li + li {
  border-left: 1px solid #ccc;
}
</style>