Adding a Custom Annotation on a PDF Programmatically
There are times when you are required to add custom annotations programmatically. An example is when you need to render bounding box (BBox) annotations for an OCR application or a RAG application.
Vue PDF Viewer supports defining annotations using the bbox approach, allowing for flexible and responsive placement on PDF pages.
This tutorial will guide you through how to highlight an important section on a PDF page with a custom marker using a simple bbox approach.
Here is a quick overview of what you’ll learn in this tutorial:
- Set up your PDF viewer with a custom component that hosts Vue PDF Viewer.
- Render annotations using the raw PDF BBox coordinate values (exact coordinates).
- Rendering multiple “layers” of annotations, each with its own style.
Tutorial for Adding Custom Annotation
1. Imports, Constants, and Types
At the heart of the system is the bounding box (BBox)—a rectangular region defined by its four edges in PDF coordinate space. In PDFs, the origin (0, 0) starts at the bottom-left of the page. The x-axis moves rightward, and the y-axis moves upward.
By storing both corners of a bounding box (left, bottom, right, top), we can easily calculate position, width, height, and aspect ratio. We also support an optional type for visual classification (e.g., "important" or "question"), and an optional note field for contextual tooltips.
To begin, we will define the basic setup for the annotation system. These constants, imports, and types form the foundation for rendering and interacting with custom annotations on top of the PDF pages.
<script setup lang="ts">
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
import { ref, onMounted } from "vue";
const samplePDF = "your-pdf-file.pdf";
// describes each highlight’s rectangle, type, and optional note.
interface BBox {
left: number; // X-coordinate of the lower-left corner
bottom: number; // Y-coordinate of the lower-left corner
right: number; // X-coordinate of the upper-right corner
top: number; // Y-coordinate of the upper-right corner
type?: "important" | "todo" | "question"; //optional
note?: string; //optional
}
//* left and bottom mark the coordinates of the box’s lower-left corner.
//* right and top mark the coordinates of the box’s upper-right corner.
//* The box’s width is right − left, and its height is top − bottom.
// array of regions to annotate.
const pageHighlights: BBox[] = [
{
left: 220,
bottom: 500,
right: 380,
top: 550,
type: "important",
note: "Critical requirement—do not skip.",
},
{
left: 100,
bottom: 300,
right: 260,
top: 360,
type: "todo",
note: "Verify numbers here before release.",
},
{
left: 300,
bottom: 700,
right: 460,
top: 760,
type: "question",
note: "Need clarification on this section.",
},
];
// ensures highlights are only rendered once per load.
let highlightsAdded = false;
// keeps track of the current tooltip element for hover notes
const tooltipRef = ref<HTMLElement | null>(null);
</script>
<script setup>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
import { ref, onMounted } from "vue";
const samplePDF = "your-pdf-file.pdf";
const pageHighlights = [
{
left: 220, // X-coordinate of the lower-left
bottom: 500, // Y-coordinate of the lower-left corner
right: 380, // X-coordinate of the upper-right
top: 550, // Y-coordinate of the upper-right corner
type: "important",
note: "Critical requirement—do not skip.",
},
{
left: 100,
bottom: 300,
right: 260,
top: 360,
type: "todo",
note: "Verify numbers here before release.",
},
{
left: 300,
bottom: 700,
right: 460,
top: 760,
type: "question",
note: "Need clarification on this section.",
},
];
// ensures highlights are only rendered once per load.
let highlightsAdded = false;
// keeps track of the current tooltip element for hover notes
const tooltipRef = ref(null);
</script>
<script lang="ts">
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
import { defineComponent } from "vue";
const samplePDF = "your-pdf-file.pdf";
// describes each highlight’s rectangle, type, and optional note.
interface BBox {
left: number; // X-coordinate of the lower-left corner
bottom: number; // Y-coordinate of the lower-left corner
right: number; // X-coordinate of the upper-right corner
top: number; // Y-coordinate of the upper-right corner
type?: "important" | "todo" | "question"; //optional
note?: string; //optional
}
//* left and bottom mark the coordinates of the box’s lower-left corner.
//* right and top mark the coordinates of the box’s upper-right corner.
//* The box’s width is right − left, and its height is top − bottom.
export default defineComponent({
components: { VPdfViewer },
data() {
return {
samplePDF: "your-pdf-file.pdf",
pageHighlights: [
{
left: 220,
bottom: 500,
right: 380,
top: 550,
type: "important",
note: "Critical requirement—do not skip.",
},
{
left: 100,
bottom: 300,
right: 260,
top: 360,
type: "todo",
note: "Verify numbers here before release.",
},
{
left: 300,
bottom: 700,
right: 460,
top: 760,
type: "question",
note: "Need clarification on this section.",
},
] as BBox[],
highlightsAdded: false, // ensures highlights are only rendered once per load.
scaleObserver: null as MutationObserver | null,
tooltipRef: null, // keeps track of the current tooltip element for hover notes
};
},
});
</script>
<script>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
import { defineComponent } from "vue";
const samplePDF = "your-pdf-file.pdf";
export default defineComponent({
components: { VPdfViewer },
data() {
return {
samplePDF: "your-pdf-file.pdf",
pageHighlights: [
{
left: 220,
bottom: 500,
right: 380,
top: 550,
type: "important",
note: "Critical requirement—do not skip.",
},
{
left: 100,
bottom: 300,
right: 260,
top: 360,
type: "todo",
note: "Verify numbers here before release.",
},
{
left: 300,
bottom: 700,
right: 460,
top: 760,
type: "question",
note: "Need clarification on this section.",
},
],
highlightsAdded: false, // ensures highlights are only rendered once per load.
scaleObserver: null,
tooltipRef: null, // keeps track of the current tooltip element for hover notes
};
},
});
</script>
2. Locating the Page Element
The findPageElemByPageIdx
function maps a logical page index (like 0, 1, 2, etc.) to the corresponding DOM element in the viewer. If the element isn’t available yet (e.g., the page hasn’t finished rendering), the function safely returns null, so you can retry later without crashing.
Why use data-page-index
?
Each PDF page rendered by <VPdfViewer>
is wrapped in an element with a data-page-index attribute. This gives you a reliable selector for targeting and injecting annotations into specific pages.
<script setup lang="ts">
function findPageElemByPageIdx(index: number): HTMLElement | null {
// find the element to annotate based on page index
return document.querySelector<HTMLElement>(`[data-page-index="${index}"]`);
}
</script>
<script setup>
function findPageElemByPageIdx(index) {
// find the element to annotate based on page index
return document.querySelector(`[data-page-index="${index}"]`);
}
</script>
<script lang="ts">
methods: {
findPageElemByPageIdx(index: number): HTMLElement | null {
// find the element to annotate based on page index
return document.querySelector<HTMLElement>(`[data-page-index="${index}"]`)
},
},
</script>
<script>
methods: {
findPageElemByPageIdx(index) {
// find the element to annotate based on page index
return document.querySelector(`[data-page-index="${index}"]`)
}
},
</script>
3. Tooltip Function
This set of helper functions manages the lifecycle of a floating tooltip. These tooltips appear when users hover over an annotated area and follow the cursor’s position.
The functions handle creating the tooltip, updating its position, and removing it cleanly, ensuring no leftover DOM elements remain.
<script setup lang="ts">
function createTooltip(): HTMLElement {
// Remove existing tooltip if it exists
if (tooltipRef.value) {
tooltipRef.value.remove()
}
// Create a new tooltip element
const tooltip = document.createElement('div')
tooltip.className = 'highlight-tooltip'
// Append it to the body and store the reference
document.body.appendChild(tooltip)
tooltipRef.value = tooltip
return tooltip
}
function positionTooltip(e: MouseEvent) {
// If tooltip exists, move it near the cursor (15px offset)
if (!tooltipRef.value) return
tooltipRef.value.style.left = `${e.clientX + 15}px`
tooltipRef.value.style.top = `${e.clientY - 15}px`
}
function removeTooltip() {
// Remove tooltip from DOM and clear the reference
if (tooltipRef.value) {
tooltipRef.value.remove()
tooltipRef.value = null
}
}
</script>
<script setup>
function createTooltip() {
// Remove existing tooltip if present
if (tooltipRef.value) {
tooltipRef.value.remove()
}
// Create a fresh tooltip div
const tooltip = document.createElement('div')
tooltip.className = 'highlight-tooltip'
// Add it to the document and save reference
document.body.appendChild(tooltip)
tooltipRef.value = tooltip
return tooltip
}
function positionTooltip(e) {
// Move tooltip next to mouse cursor (with offset)
if (!tooltipRef.value) return
tooltipRef.value.style.left = `${e.clientX + 15}px`
tooltipRef.value.style.top = `${e.clientY - 15}px`
}
function removeTooltip() {
// Clean up tooltip and unset the ref
if (tooltipRef.value) {
tooltipRef.value.remove()
tooltipRef.value = null
}
}
</script>
<script lang="ts">
methods: {
createTooltip(): HTMLElement {
// Remove old tooltip if one is already on screen
if (this.tooltipRef) {
this.tooltipRef.remove()
}
// Create a new tooltip and attach it to the body
const tooltip = document.createElement('div')
tooltip.className = 'highlight-tooltip'
document.body.appendChild(tooltip)
// Save the tooltip reference for reuse/removal
this.tooltipRef = tooltip
return tooltip
},
positionTooltip(e: MouseEvent) {
// Update tooltip position relative to mouse
if (!this.tooltipRef) return
this.tooltipRef.style.left = `${e.clientX + 15}px`
this.tooltipRef.style.top = `${e.clientY - 15}px`
},
removeTooltip() {
// Remove tooltip from DOM and clear reference
if (this.tooltipRef) {
this.tooltipRef.remove()
this.tooltipRef = null
}
}
}
</script>
<script>
methods: {
createTooltip() {
// Remove existing tooltip if already there
if (this.tooltipRef) {
this.tooltipRef.remove()
}
// Make a new tooltip and append to body
const tooltip = document.createElement('div')
tooltip.className = 'highlight-tooltip'
document.body.appendChild(tooltip)
// Store reference for future use
this.tooltipRef = tooltip
return tooltip
},
positionTooltip(e) {
// Set tooltip position relative to mouse (with offset)
if (!this.tooltipRef) return
this.tooltipRef.style.left = `${e.clientX + 15}px`
this.tooltipRef.style.top = `${e.clientY - 15}px`
},
removeTooltip() {
// Remove tooltip and clear reference
if (this.tooltipRef) {
this.tooltipRef.remove()
this.tooltipRef = null
}
}
}
</script>
4. Rendering a Highlight
The addHighlight
function takes a single BBox, calculates its pixel-based position and dimensions, and creates a styled, absolutely positioned <div>
overlay. If the BBox includes a note, the function wires up tooltip events to display that content on hover.
<script setup lang="ts">
function addHighlight(bbox: BBox, pageElement: HTMLElement, scale = 1) {
// Coordinate math: convert PDF-space to screen-space
const vh = pageElement.offsetHeight
const x = bbox.left * scale
const y = vh - bbox.top * scale
const width = (bbox.right - bbox.left) * scale
const height = (bbox.top - bbox.bottom) * scale
// Element setup: create a highlight div with appropriate classes
const hl = document.createElement('div')
hl.classList.add('highlight', bbox.type)
// Inline styles: position and size the element over PDF content
Object.assign(hl.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
borderRadius: '2px'
})
hl.style.pointerEvents = 'auto'
hl.style.cursor = bbox.note ? 'help' : 'default'
// Tooltip wiring: attach events if a note exists
if (bbox.note) {
hl.dataset.note = bbox.note
hl.addEventListener('mouseenter', (e) => {
const tooltip = createTooltip()
tooltip.textContent = bbox.note!
positionTooltip(e as MouseEvent)
})
hl.addEventListener('mousemove', positionTooltip)
hl.addEventListener('mouseleave', removeTooltip)
}
// DOM injection: add the highlight to the correct PDF page
pageElement.appendChild(hl)
}
</script>
<script setup>
function addHighlight(bbox, pageElement, scale = 1) {
// Coordinate math: convert PDF-space to screen-space
const vh = pageElement.offsetHeight
const x = bbox.left * scale
const y = vh - bbox.top * scale
const width = (bbox.right - bbox.left) * scale
const height = (bbox.top - bbox.bottom) * scale
// Create the highlight element
const hl = document.createElement('div')
hl.classList.add('highlight', bbox.type)
// Apply inline positioning styles
Object.assign(hl.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
borderRadius: '2px'
})
hl.style.pointerEvents = 'auto'
hl.style.cursor = bbox.note ? 'help' : 'default'
// Handle tooltip if note is present
if (bbox.note) {
hl.dataset.note = bbox.note
hl.addEventListener('mouseenter', (e) => {
const tooltip = createTooltip()
tooltip.textContent = bbox.note
positionTooltip(e)
})
hl.addEventListener('mousemove', positionTooltip)
hl.addEventListener('mouseleave', removeTooltip)
}
// Inject into DOM
pageElement.appendChild(hl)
}
</script>
<script lang="ts">
methods: {
addHighlight(bbox: BBox, pageElement: HTMLElement, scale = 1) {
// Coordinate math: convert PDF-space to screen-space
const vh = pageElement.offsetHeight
const x = bbox.left * scale
const y = vh - bbox.top * scale
const width = (bbox.right - bbox.left) * scale
const height = (bbox.top - bbox.bottom) * scale
// Create the highlight element
const hl = document.createElement('div')
hl.classList.add('highlight', bbox.type)
// Inline style setup
Object.assign(hl.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
borderRadius: '2px'
})
hl.style.pointerEvents = 'auto'
hl.style.cursor = bbox.note ? 'help' : 'default'
// Tooltip if note is defined
if (bbox.note) {
hl.dataset.note = bbox.note
hl.addEventListener('mouseenter', (e) => {
const tooltip = this.createTooltip()
tooltip.textContent = bbox.note!
this.positionTooltip(e as MouseEvent)
})
hl.addEventListener('mousemove', this.positionTooltip)
hl.addEventListener('mouseleave', this.removeTooltip)
}
// Append to page element
pageElement.appendChild(hl)
}
}
</script>
<script>
methods: {
addHighlight(bbox, pageElement, scale = 1) {
// Coordinate math: convert PDF-space to screen-space
const vh = pageElement.offsetHeight
const x = bbox.left * scale
const y = vh - bbox.top * scale
const width = (bbox.right - bbox.left) * scale
const height = (bbox.top - bbox.bottom) * scale
// Create highlight div
const hl = document.createElement('div')
hl.classList.add('highlight', bbox.type)
// Apply inline positioning/sizing
Object.assign(hl.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
borderRadius: '2px'
})
hl.style.pointerEvents = 'auto'
hl.style.cursor = bbox.note ? 'help' : 'default'
// Handle hover tooltip events if note exists
if (bbox.note) {
hl.dataset.note = bbox.note
hl.addEventListener('mouseenter', (e) => {
const tooltip = this.createTooltip()
tooltip.textContent = bbox.note
this.positionTooltip(e)
})
hl.addEventListener('mousemove', this.positionTooltip)
hl.addEventListener('mouseleave', this.removeTooltip)
}
// Inject into the PDF page element
pageElement.appendChild(hl)
}
}
</script>
5. Initial Load & Highlight Injection
This step ensures highlights are injected only once, after the PDF page is fully rendered. It uses polling to wait for the target DOM element to appear, then iterates over all BBox annotations and applies them.
It also respects the viewer’s current scale factor to accurately position highlights even on zoomed-in pages.
<script setup lang="ts">
function handlePDFLoaded() {
const checkAndAddHighlights = () => {
// Skip if already added
if (highlightsAdded.value) return
// Retry until page element is ready
const pageEl = findPageElemByPageIdx(0)
if (!pageEl) {
setTimeout(checkAndAddHighlights, 500)
return
}
// Prevent duplicates
if (pageEl.querySelector('.highlight')) return
// Read the scale factor from CSS
const wrapper = pageEl.closest('.vpv-pages-inner-wrapper') as HTMLElement | null
if (!wrapper) return
const scale = parseFloat(
getComputedStyle(wrapper).getPropertyValue('--scale-factor') || '1'
)
// Apply all highlights
pageHighlights.value.forEach((bbox) => addHighlight(bbox, pageEl, scale))
// Set completion flag
highlightsAdded.value = true
}
// Called on @loaded
checkAndAddHighlights()
}
</script>
<template>
<VPdfViewer :src="samplePDF" @loaded="handlePDFLoaded" />
</template>
<script setup>
function handlePDFLoaded() {
const checkAndAddHighlights = () => {
// Already done? Skip
if (highlightsAdded.value) return
// Wait for page DOM
const pageEl = findPageElemByPageIdx(0)
if (!pageEl) {
setTimeout(checkAndAddHighlights, 500)
return
}
// Avoid double-inserting highlights
if (pageEl.querySelector('.highlight')) return
// Read scale from viewer
const wrapper = pageEl.closest('.vpv-pages-inner-wrapper')
if (!wrapper) return
const scale = parseFloat(
getComputedStyle(wrapper).getPropertyValue('--scale-factor') || '1'
)
// Apply highlights for each BBox
pageHighlights.value.forEach((bbox) => addHighlight(bbox, pageEl, scale))
// Remember that highlights are added
highlightsAdded.value = true
}
// Trigger this after PDF loads
checkAndAddHighlights()
}
</script>
<template>
<VPdfViewer :src="samplePDF" @loaded="handlePDFLoaded" />
</template>
<script lang="ts">
export default {
methods: {
handlePDFLoaded() {
const checkAndAddHighlights = () => {
// Duplicate guard: skip if highlights already added
if (this.highlightsAdded) return
// Polling loop: wait until the page element exists
const pageEl = this.findPageElemByPageIdx(0)
if (!pageEl) {
setTimeout(checkAndAddHighlights, 500)
return
}
// Skip if highlight divs already exist
if (pageEl.querySelector('.highlight')) return
// Scale retrieval: get scale from viewer CSS variable
const wrapper = pageEl.closest('.vpv-pages-inner-wrapper') as HTMLElement | null
if (!wrapper) return
const scale = parseFloat(
getComputedStyle(wrapper).getPropertyValue('--scale-factor') || '1'
)
// Highlight loop: add highlight divs for each region
this.pageHighlights.forEach((bbox) => this.addHighlight(bbox, pageEl, scale))
// Mark as done
this.highlightsAdded = true
}
// Trigger: on viewer’s @loaded event
checkAndAddHighlights()
}
}
}
</script>
<template>
<VPdfViewer :src="samplePDF" @loaded="handlePDFLoaded" />
</template>
<script>
export default {
methods: {
handlePDFLoaded() {
const checkAndAddHighlights = () => {
// Skip if highlights were already rendered
if (this.highlightsAdded) return
// Wait for the page DOM element
const pageEl = this.findPageElemByPageIdx(0)
if (!pageEl) {
setTimeout(checkAndAddHighlights, 500)
return
}
// Avoid duplicates
if (pageEl.querySelector('.highlight')) return
// Grab wrapper to read scale factor
const wrapper = pageEl.closest('.vpv-pages-inner-wrapper')
if (!wrapper) return
const scale = parseFloat(
getComputedStyle(wrapper).getPropertyValue('--scale-factor') || '1'
)
// Loop over bounding boxes and add highlights
this.pageHighlights.forEach((bbox) => this.addHighlight(bbox, pageEl, scale))
// Mark flag to prevent re-highlighting
this.highlightsAdded = true
}
// Run on viewer load
checkAndAddHighlights()
}
}
}
</script>
<template>
<VPdfViewer :src="samplePDF" @loaded="handlePDFLoaded" />
</template>
6. Keeping Highlights in Sync
This function tears down every existing .highlight and tooltip, resets the "added" flag, and re-invokes your load logic after a brief delay—so annotations always realign when the viewer is resized or zoomed.
<script setup lang="ts">
function handleScaleChange() {
// Remove all highlights from the DOM
document.querySelectorAll('.highlight').forEach((el) => el.remove())
// Clear tooltip if visible
removeTooltip()
// Reset flag to allow highlights to be redrawn
highlightsAdded = false
// Re-run highlight logic after short delay (to allow re-scaling)
setTimeout(() => handlePDFLoaded(), 300)
}
</script>
<script setup>
function handleScaleChange() {
// Remove all highlights from the DOM
document.querySelectorAll('.highlight').forEach((el) => el.remove())
// Clear tooltip if visible
removeTooltip()
// Reset flag to trigger re-rendering
highlightsAdded = false
// Re-run highlight logic after short delay (to allow re-scaling)
setTimeout(() => handlePDFLoaded(), 300)
}
</script>
<script lang="ts">
export default {
methods: {
handleScaleChange() {
// Remove all highlights from the DOM
document.querySelectorAll('.highlight').forEach((el) => el.remove())
// Clear tooltip if visible
this.removeTooltip()
// Reset flag to trigger re-rendering
this.highlightsAdded = false
// Re-run highlight logic after short delay (to allow re-scaling)
setTimeout(() => this.handlePDFLoaded(), 300)
}
}
}
</script>
<script>
export default {
methods: {
handleScaleChange() {
// Remove all highlight overlays
document.querySelectorAll('.highlight').forEach((el) => el.remove())
// Clear tooltip if visible
this.removeTooltip()
// Reset flag to trigger re-rendering
this.highlightsAdded = false
// Re-run highlight logic after short delay (to allow re-scaling)
setTimeout(() => this.handlePDFLoaded(), 300)
}
}
}
</script>
7. Lifecycle Hook & Cleanup
The onMounted block sets up everything needed to keep annotations responsive. It:
- Listens for window resize events
- Listens for
pdfViewerScaleChanged
events emitted by<VPdfViewer>
- Attaches a
MutationObserver
to detect structural changes in the viewer
It also returns a cleanup function that removes all registered listeners and cleans up any remaining tooltips.
<script setup lang="ts">
onMounted(() => {
// Mount-time setup
// Listen for window resize and custom viewer scale change events
window.addEventListener('resize', handleScaleChange)
document.addEventListener('pdfViewerScaleChanged', handleScaleChange)
// Create a MutationObserver to detect inline style changes (e.g. scale animations)
const scaleObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style' && m.target.nodeName === 'DIV') {
handleScaleChange()
break
}
}
})
// Delay observing until wrapper exists
setTimeout(() => {
const wrapper = document.querySelector('.vpv-pages-inner-wrapper')
if (wrapper) {
scaleObserver.observe(wrapper, { attributes: true, attributeFilter: ['style'] })
}
}, 1000)
// Cleanup function: remove all listeners and observer
return () => {
window.removeEventListener('resize', handleScaleChange)
document.removeEventListener('pdfViewerScaleChanged', handleScaleChange)
scaleObserver.disconnect()
removeTooltip()
}
})
</script>
<script setup>
onMounted(() => {
// Mount-time setup
// Listen for window resize and custom viewer scale change events
window.addEventListener('resize', handleScaleChange)
document.addEventListener('pdfViewerScaleChanged', handleScaleChange)
// Create a MutationObserver to detect inline style changes (e.g. scale animations)
const scaleObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style' && m.target.nodeName === 'DIV') {
handleScaleChange()
break
}
}
})
// Delay observing until wrapper exists
setTimeout(() => {
const wrapper = document.querySelector('.vpv-pages-inner-wrapper')
if (wrapper) {
scaleObserver.observe(wrapper, { attributes: true, attributeFilter: ['style'] })
}
}, 1000)
// Cleanup function: remove all listeners and observer
return () => {
window.removeEventListener('resize', handleScaleChange)
document.removeEventListener('pdfViewerScaleChanged', handleScaleChange)
scaleObserver.disconnect()
removeTooltip()
}
})
</script>
<script lang="ts">
export default {
mounted() {
// Mount-time setup
window.addEventListener('resize', this.handleScaleChange)
document.addEventListener('pdfViewerScaleChanged', this.handleScaleChange)
// Create and store observer
this.scaleObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style' && m.target.nodeName === 'DIV') {
this.handleScaleChange()
break
}
}
})
// Delay attaching observer until wrapper appears
setTimeout(() => {
const wrapper = document.querySelector('.vpv-pages-inner-wrapper')
if (wrapper && this.scaleObserver) {
this.scaleObserver.observe(wrapper, {
attributes: true,
attributeFilter: ['style']
})
}
}, 1000)
},
beforeUnmount() {
// Cleanup function
window.removeEventListener('resize', this.handleScaleChange)
document.removeEventListener('pdfViewerScaleChanged', this.handleScaleChange)
this.scaleObserver?.disconnect()
this.removeTooltip()
}
}
</script>
<script>
export default {
mounted() {
// Mount-time setup
window.addEventListener('resize', this.handleScaleChange)
document.addEventListener('pdfViewerScaleChanged', this.handleScaleChange)
// Create and store observer
this.scaleObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style' && m.target.nodeName === 'DIV') {
this.handleScaleChange()
break
}
}
})
// Delay attaching observer until wrapper appears
setTimeout(() => {
const wrapper = document.querySelector('.vpv-pages-inner-wrapper')
if (wrapper) {
this.scaleObserver.observe(wrapper, { attributes: true, attributeFilter: ['style'] })
}
}, 1000)
},
beforeUnmount() {
// Cleanup function
window.removeEventListener('resize', this.handleScaleChange)
document.removeEventListener('pdfViewerScaleChanged', this.handleScaleChange)
this.scaleObserver.disconnect()
this.removeTooltip()
}
}
</script>
Complete Example
Here’s how everything fits together:
- The VPdfViewer component loads and renders the PDF.
- When the PDF is loaded, the viewer checks for highlights to display.
- Each highlight is rendered as a styled overlay box, with optional notes shown on hover.
- Highlights automatically adjust on zoom or resize.
<script setup lang="ts">
import { VPdfViewer } from '@vue-pdf-viewer/viewer'
import { ref, onMounted } from 'vue'
const samplePDF = 'your-pdf-file.pdf'
interface BBox {
left: number
bottom: number
right: number
top: number
type: 'important' | 'todo' | 'question'
note?: string
}
const pageHighlights: BBox[] = [
{
left: 220,
bottom: 500,
right: 380,
top: 550,
type: 'important',
note: 'Critical requirement—do not skip.'
},
{
left: 100,
bottom: 300,
right: 260,
top: 360,
type: 'todo',
note: 'Verify numbers here before release.'
},
{
left: 300,
bottom: 700,
right: 460,
top: 760,
type: 'question',
note: 'Need clarification on this section.'
}
]
let highlightsAdded = false
const tooltipRef = ref<HTMLElement | null>(null)
function findPageElemByPageIdx(index: number): HTMLElement | null {
return document.querySelector<HTMLElement>(`[data-page-index="${index}"]`)
}
function addHighlight(bbox: BBox, pageElement: HTMLElement, scale = 1) {
const vh = pageElement.offsetHeight
const x = bbox.left * scale
const y = vh - bbox.top * scale
const width = (bbox.right - bbox.left) * scale
const height = (bbox.top - bbox.bottom) * scale
const hl = document.createElement('div')
hl.classList.add('highlight', bbox.type)
Object.assign(hl.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
borderRadius: '2px'
})
hl.style.pointerEvents = 'auto'
hl.style.cursor = bbox.note ? 'help' : 'default'
if (bbox.note) {
hl.dataset.note = bbox.note
hl.addEventListener('mouseenter', (e) => {
const tooltip = createTooltip()
tooltip.textContent = bbox.note!
positionTooltip(e as MouseEvent)
})
hl.addEventListener('mousemove', positionTooltip)
hl.addEventListener('mouseleave', removeTooltip)
}
pageElement.appendChild(hl)
}
function createTooltip(): HTMLElement {
if (tooltipRef.value) {
tooltipRef.value.remove()
}
const tooltip = document.createElement('div')
tooltip.className = 'highlight-tooltip'
document.body.appendChild(tooltip)
tooltipRef.value = tooltip
return tooltip
}
function positionTooltip(e: MouseEvent) {
if (!tooltipRef.value) return
tooltipRef.value.style.left = `${e.clientX + 15}px`
tooltipRef.value.style.top = `${e.clientY - 15}px`
}
function removeTooltip() {
if (tooltipRef.value) {
tooltipRef.value.remove()
tooltipRef.value = null
}
}
function handlePDFLoaded() {
const checkAndAddHighlights = () => {
if (highlightsAdded) return
const pageEl = findPageElemByPageIdx(0)
if (!pageEl) {
setTimeout(checkAndAddHighlights, 500)
return
}
if (pageEl.querySelector('.highlight')) return
const wrapper = pageEl.closest('.vpv-pages-inner-wrapper') as HTMLElement
if (!wrapper) return
const scale = parseFloat(getComputedStyle(wrapper).getPropertyValue('--scale-factor') || '1')
pageHighlights.forEach((bbox) => addHighlight(bbox, pageEl, scale))
highlightsAdded = true
}
checkAndAddHighlights()
}
function handleScaleChange() {
document.querySelectorAll('.highlight').forEach((el) => el.remove())
removeTooltip()
highlightsAdded = false
setTimeout(() => handlePDFLoaded(), 300)
}
onMounted(() => {
window.addEventListener('resize', handleScaleChange)
document.addEventListener('pdfViewerScaleChanged', handleScaleChange)
const scaleObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style' && m.target.nodeName === 'DIV') {
handleScaleChange()
break
}
}
})
setTimeout(() => {
const wrapper = document.querySelector('.vpv-pages-inner-wrapper')
if (wrapper) {
scaleObserver.observe(wrapper, { attributes: true, attributeFilter: ['style'] })
}
}, 1000)
return () => {
window.removeEventListener('resize', handleScaleChange)
document.removeEventListener('pdfViewerScaleChanged', handleScaleChange)
scaleObserver.disconnect()
removeTooltip()
}
})
</script>
<template>
<div class="pdf">
<VPdfViewer :src="samplePDF" @loaded="handlePDFLoaded" />
</div>
</template>
<style>
.pdf {
width: 80%;
height: 80vh;
margin: 0 auto;
}
.boxCenter {
text-align: center;
margin-bottom: 20px;
}
.highlight {
position: absolute;
z-index: 5;
border-radius: 2px;
transition: opacity 0.2s ease;
pointer-events: auto !important;
}
.highlight:hover {
opacity: 0.8;
border: 1px solid #ffffffb3;
}
.highlight.important {
background-color: #ffa50066;
}
.highlight.todo {
background-color: #0080004d;
}
.highlight.question {
background-color: #0000ff4d;
}
.highlight-tooltip {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 14px;
border-radius: 4px;
pointer-events: none;
white-space: pre-wrap;
z-index: 9999;
max-width: 300px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
position: fixed;
}
</style>
<script setup>
import { VPdfViewer } from '@vue-pdf-viewer/viewer'
import { ref, onMounted } from 'vue'
const samplePDF = 'your-pdf-file.pdf'
const pageHighlights = [
{
left: 220,
bottom: 500,
right: 380,
top: 550,
type: 'important',
note: 'Critical requirement—do not skip.'
},
{
left: 100,
bottom: 300,
right: 260,
top: 360,
type: 'todo',
note: 'Verify numbers here before release.'
},
{
left: 300,
bottom: 700,
right: 460,
top: 760,
type: 'question',
note: 'Need clarification on this section.'
}
]
let highlightsAdded = false
const tooltipRef = ref(null)
function findPageElemByPageIdx(index) {
return document.querySelector(`[data-page-index="${index}"]`)
}
function createTooltip() {
if (tooltipRef.value) {
tooltipRef.value.remove()
}
const tooltip = document.createElement('div')
tooltip.className = 'highlight-tooltip'
document.body.appendChild(tooltip)
tooltipRef.value = tooltip
return tooltip
}
function positionTooltip(e) {
if (!tooltipRef.value) return
tooltipRef.value.style.left = `${e.clientX + 15}px`
tooltipRef.value.style.top = `${e.clientY - 15}px`
}
function removeTooltip() {
if (tooltipRef.value) {
tooltipRef.value.remove()
tooltipRef.value = null
}
}
function addHighlight(bbox, pageElement, scale = 1) {
const vh = pageElement.offsetHeight
const x = bbox.left * scale
const y = vh - bbox.top * scale
const width = (bbox.right - bbox.left) * scale
const height = (bbox.top - bbox.bottom) * scale
const hl = document.createElement('div')
hl.classList.add('highlight', bbox.type)
Object.assign(hl.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
borderRadius: '2px'
})
hl.style.pointerEvents = 'auto'
hl.style.cursor = bbox.note ? 'help' : 'default'
if (bbox.note) {
hl.dataset.note = bbox.note
hl.addEventListener('mouseenter', (e) => {
const tooltip = createTooltip()
tooltip.textContent = bbox.note
positionTooltip(e)
})
hl.addEventListener('mousemove', positionTooltip)
hl.addEventListener('mouseleave', removeTooltip)
}
pageElement.appendChild(hl)
}
function handlePDFLoaded() {
const checkAndAddHighlights = () => {
if (highlightsAdded) return
const pageEl = findPageElemByPageIdx(0)
if (!pageEl) {
setTimeout(checkAndAddHighlights, 500)
return
}
if (pageEl.querySelector('.highlight')) return
const wrapper = pageEl.closest('.vpv-pages-inner-wrapper')
if (!wrapper) return
const scale = parseFloat(getComputedStyle(wrapper).getPropertyValue('--scale-factor') || '1')
pageHighlights.forEach((bbox) => addHighlight(bbox, pageEl, scale))
highlightsAdded = true
}
checkAndAddHighlights()
}
function handleScaleChange() {
document.querySelectorAll('.highlight').forEach((el) => el.remove())
removeTooltip()
highlightsAdded = false
setTimeout(() => handlePDFLoaded(), 300)
}
onMounted(() => {
window.addEventListener('resize', handleScaleChange)
document.addEventListener('pdfViewerScaleChanged', handleScaleChange)
const scaleObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style' && m.target.nodeName === 'DIV') {
handleScaleChange()
break
}
}
})
setTimeout(() => {
const wrapper = document.querySelector('.vpv-pages-inner-wrapper')
if (wrapper) {
scaleObserver.observe(wrapper, { attributes: true, attributeFilter: ['style'] })
}
}, 1000)
return () => {
window.removeEventListener('resize', handleScaleChange)
document.removeEventListener('pdfViewerScaleChanged', handleScaleChange)
scaleObserver.disconnect()
removeTooltip()
}
})
</script>
<template>
<div class="pdf">
<VPdfViewer :src="samplePDF" @loaded="handlePDFLoaded" />
</div>
</template>
<style>
.pdf {
width: 80%;
height: 80vh;
margin: 0 auto;
}
.boxCenter {
text-align: center;
margin-bottom: 20px;
}
.highlight {
position: absolute;
z-index: 5;
border-radius: 2px;
transition: opacity 0.2s ease;
pointer-events: auto !important;
}
.highlight:hover {
opacity: 0.8;
border: 1px solid #ffffffb3;
}
.highlight.important {
background-color: #ffa50066;
}
.highlight.todo {
background-color: #0080004d;
}
.highlight.question {
background-color: #0000ff4d;
}
.highlight-tooltip {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 14px;
border-radius: 4px;
pointer-events: none;
white-space: pre-wrap;
z-index: 9999;
max-width: 300px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
position: fixed;
}
</style>
<script lang="ts">
import { defineComponent } from 'vue'
import { VPdfViewer } from '@vue-pdf-viewer/viewer'
interface BBox {
left: number
bottom: number
right: number
top: number
type: 'important' | 'todo' | 'question'
note?: string
}
export default defineComponent({
components: { VPdfViewer },
data() {
return {
samplePDF: 'your-pdf-file.pdf',
pageHighlights: [
{
left: 220,
bottom: 500,
right: 380,
top: 550,
type: 'important',
note: 'Critical requirement—do not skip.'
},
{
left: 100,
bottom: 300,
right: 260,
top: 360,
type: 'todo',
note: 'Verify numbers here before release.'
},
{
left: 300,
bottom: 700,
right: 460,
top: 760,
type: 'question',
note: 'Need clarification on this section.'
}
] as BBox[],
highlightsAdded: false,
tooltipRef: null as HTMLElement | null,
scaleObserver: null as MutationObserver | null
}
},
methods: {
findPageElemByPageIdx(index: number): HTMLElement | null {
return document.querySelector<HTMLElement>(`[data-page-index="${index}"]`)
},
addHighlight(bbox: BBox, pageElement: HTMLElement, scale = 1) {
const vh = pageElement.offsetHeight
const x = bbox.left * scale
const y = vh - bbox.top * scale
const width = (bbox.right - bbox.left) * scale
const height = (bbox.top - bbox.bottom) * scale
const hl = document.createElement('div')
hl.classList.add('highlight', bbox.type)
Object.assign(hl.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
borderRadius: '2px'
})
hl.style.pointerEvents = 'auto'
hl.style.cursor = bbox.note ? 'help' : 'default'
if (bbox.note) {
hl.dataset.note = bbox.note
hl.addEventListener('mouseenter', (e) => {
const tooltip = this.createTooltip()
tooltip.textContent = bbox.note!
this.positionTooltip(e as MouseEvent)
})
hl.addEventListener('mousemove', this.positionTooltip)
hl.addEventListener('mouseleave', this.removeTooltip)
}
pageElement.appendChild(hl)
},
createTooltip(): HTMLElement {
if (this.tooltipRef) {
this.tooltipRef.remove()
}
const tooltip = document.createElement('div')
tooltip.className = 'highlight-tooltip'
document.body.appendChild(tooltip)
this.tooltipRef = tooltip
return tooltip
},
positionTooltip(e: MouseEvent) {
if (!this.tooltipRef) return
this.tooltipRef.style.left = `${e.clientX + 15}px`
this.tooltipRef.style.top = `${e.clientY - 15}px`
},
removeTooltip() {
if (this.tooltipRef) {
this.tooltipRef.remove()
this.tooltipRef = null
}
},
handlePDFLoaded() {
const checkAndAddHighlights = () => {
if (this.highlightsAdded) return
const pageEl = this.findPageElemByPageIdx(0)
if (!pageEl) {
setTimeout(checkAndAddHighlights, 500)
return
}
if (pageEl.querySelector('.highlight')) return
const wrapper = pageEl.closest('.vpv-pages-inner-wrapper') as HTMLElement | null
if (!wrapper) return
const scale = parseFloat(
getComputedStyle(wrapper).getPropertyValue('--scale-factor') || '1'
)
this.pageHighlights.forEach((bbox) => this.addHighlight(bbox, pageEl, scale))
this.highlightsAdded = true
}
checkAndAddHighlights()
},
handleScaleChange() {
document.querySelectorAll('.highlight').forEach((el) => el.remove())
this.highlightsAdded = false
setTimeout(() => this.handlePDFLoaded(), 300)
}
},
mounted() {
window.addEventListener('resize', this.handleScaleChange)
document.addEventListener('pdfViewerScaleChanged', this.handleScaleChange)
this.scaleObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style' && m.target.nodeName === 'DIV') {
this.handleScaleChange()
break
}
}
})
setTimeout(() => {
const wrapper = document.querySelector('.vpv-pages-inner-wrapper')
if (wrapper && this.scaleObserver) {
this.scaleObserver.observe(wrapper, {
attributes: true,
attributeFilter: ['style']
})
}
}, 1000)
},
beforeUnmount() {
window.removeEventListener('resize', this.handleScaleChange)
document.removeEventListener('pdfViewerScaleChanged', this.handleScaleChange)
this.scaleObserver?.disconnect()
}
})
</script>
<template>
<h1 class="boxCenter">PDF Viewer with Annotations</h1>
<div class="pdf">
<VPdfViewer :src="samplePDF" @loaded="handlePDFLoaded" />
</div>
</template>
<style>
.pdf {
width: 80%;
height: 80vh;
margin: 0 auto;
}
.boxCenter {
text-align: center;
margin-bottom: 20px;
}
.highlight {
position: absolute;
z-index: 5;
border-radius: 2px;
transition: opacity 0.2s ease;
pointer-events: auto !important;
}
.highlight:hover {
opacity: 0.8;
border: 1px solid #ffffffb3;
}
.highlight.important {
background-color: #ffa50066;
}
.highlight.todo {
background-color: #0080004d;
}
.highlight.question {
background-color: #0000ff4d;
}
.highlight-tooltip {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 14px;
border-radius: 4px;
pointer-events: none;
white-space: pre-wrap;
z-index: 9999;
max-width: 300px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
position: fixed;
}
</style>
<script>
import { VPdfViewer } from '@vue-pdf-viewer/viewer'
export default {
components: {
VPdfViewer
},
data() {
return {
samplePDF: 'your-pdf-file.pdf',
pageHighlights: [
{
left: 220,
bottom: 500,
right: 380,
top: 550,
type: 'important',
note: 'Critical requirement—do not skip.'
},
{
left: 100,
bottom: 300,
right: 260,
top: 360,
type: 'todo',
note: 'Verify numbers here before release.'
},
{
left: 300,
bottom: 700,
right: 460,
top: 760,
type: 'question',
note: 'Need clarification on this section.'
}
],
highlightsAdded: false,
scaleObserver: null,
tooltipRef: null
}
},
methods: {
findPageElemByPageIdx(index) {
return document.querySelector(`[data-page-index="${index}"]`)
},
addHighlight(bbox, pageElement, scale = 1) {
const vh = pageElement.offsetHeight
const x = bbox.left * scale
const y = vh - bbox.top * scale
const width = (bbox.right - bbox.left) * scale
const height = (bbox.top - bbox.bottom) * scale
const hl = document.createElement('div')
hl.classList.add('highlight', bbox.type)
Object.assign(hl.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
borderRadius: '2px'
})
hl.style.pointerEvents = 'auto'
hl.style.cursor = bbox.note ? 'help' : 'default'
if (bbox.note) {
hl.dataset.note = bbox.note
hl.addEventListener('mouseenter', (e) => {
const tooltip = this.createTooltip()
tooltip.textContent = bbox.note
this.positionTooltip(e)
})
hl.addEventListener('mousemove', this.positionTooltip)
hl.addEventListener('mouseleave', this.removeTooltip)
}
pageElement.appendChild(hl)
},
createTooltip() {
if (this.tooltipRef) {
this.tooltipRef.remove()
}
const tooltip = document.createElement('div')
tooltip.className = 'highlight-tooltip'
document.body.appendChild(tooltip)
this.tooltipRef = tooltip
return tooltip
},
positionTooltip(e) {
if (!this.tooltipRef) return
this.tooltipRef.style.left = `${e.clientX + 15}px`
this.tooltipRef.style.top = `${e.clientY - 15}px`
},
removeTooltip() {
if (this.tooltipRef) {
this.tooltipRef.remove()
this.tooltipRef = null
}
},
handlePDFLoaded() {
const checkAndAddHighlights = () => {
if (this.highlightsAdded) return
const pageEl = this.findPageElemByPageIdx(0)
if (!pageEl) {
setTimeout(checkAndAddHighlights, 500)
return
}
if (pageEl.querySelector('.highlight')) return
const wrapper = pageEl.closest('.vpv-pages-inner-wrapper')
if (!wrapper) return
const scale = parseFloat(
getComputedStyle(wrapper).getPropertyValue('--scale-factor') || '1'
)
this.pageHighlights.forEach((bbox) => this.addHighlight(bbox, pageEl, scale))
this.highlightsAdded = true
}
checkAndAddHighlights()
},
handleScaleChange() {
document.querySelectorAll('.highlight').forEach((el) => el.remove())
this.highlightsAdded = false
setTimeout(() => this.handlePDFLoaded(), 300)
}
},
mounted() {
window.addEventListener('resize', this.handleScaleChange)
document.addEventListener('pdfViewerScaleChanged', this.handleScaleChange)
this.scaleObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style' && m.target.nodeName === 'DIV') {
this.handleScaleChange()
break
}
}
})
setTimeout(() => {
const wrapper = document.querySelector('.vpv-pages-inner-wrapper')
if (wrapper) {
this.scaleObserver.observe(wrapper, { attributes: true, attributeFilter: ['style'] })
}
}, 1000)
},
beforeUnmount() {
window.removeEventListener('resize', this.handleScaleChange)
document.removeEventListener('pdfViewerScaleChanged', this.handleScaleChange)
this.scaleObserver.disconnect()
}
}
</script>
<template>
<div class="pdf">
<VPdfViewer :src="samplePDF" @loaded="handlePDFLoaded" />
</div>
</template>
<style>
.pdf {
width: 80%;
height: 80vh;
margin: 0 auto;
}
.boxCenter {
text-align: center;
margin-bottom: 20px;
}
.highlight {
position: absolute;
z-index: 5;
border-radius: 2px;
transition: opacity 0.2s ease;
pointer-events: auto !important;
}
.highlight:hover {
opacity: 0.8;
border: 1px solid #ffffffb3;
}
.highlight.important {
background-color: #ffa50066;
}
.highlight.todo {
background-color: #0080004d;
}
.highlight.question {
background-color: #0000ff4d;
}
.highlight-tooltip {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 14px;
border-radius: 4px;
pointer-events: none;
white-space: pre-wrap;
z-index: 9999;
max-width: 300px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
position: fixed;
}
</style>