Appearance
Jump to the Next Bbox Highlight Area
Scenario
When the PDF document is loaded, Vue PDF Viewer programmatically highlights specific regions using bounding box coordinates and allows users to jump to the next highlight area:
- Highlight multiple regions on page 1 (first page) with exact coordinates
- Highlight additional regions on page 2 (second page) with exact coordinates
- Each highlighted region uses a different color to distinguish different areas of interest
- Automatically scales highlights when users zoom in or out
- Allow users to jump to the next highlight area by clicking an item in the left sidebar
The bounding box coordinates define precise rectangular areas on the PDF pages, making it ideal for:
- Highlighting table cells, form fields, or specific document sections
- Marking regions detected by OCR or document analysis tools
- Displaying annotations from external data sources with precise positioning
- Visualizing search results or text/table extraction
What to Use
Use the src prop on VPdfViewer to load a PDF file. To draw bbox overlays on each page, use the #pageOverlay slot.
Slot
| Name | Objective |
|---|---|
pageIndex | Zero-based index of the current page. Filter which highlights render on this page. |
scale | Current scale/zoom level of the page from the viewport. Multiply PDF-point bbox values so overlays stay aligned when zoom changes. |
To navigate to a page with bbox highlight, use pageControl on the viewer instance (via ref).
Instance API
| Name | Objective |
|---|---|
goToPage | Force the viewer to go to a certain page when the user selects a highlight from the sidebar. Uses 1-based page numbers (highlight.pageIndex + 1). |
totalPages | Return the total number of pages after the PDF loads. Used to mark highlights whose pageIndex is out of range as unavailable in the sidebar. |
vue
<script setup lang="ts">
import { nextTick, ref, type CSSProperties, type Ref } from 'vue'
import { VPdfViewer } from '@vue-pdf-viewer/viewer'
// Bbox values are in PDF page space (points), origin top-left of the page.
// They are multiplied by `scale` from the pageOverlay slot when rendered.
interface BoundingBox {
x: number
y: number
width: number
height: number
}
interface HighlightDefinition {
id: string
pageIndex: number // zero-based; matches pageOverlay `pageIndex`
index: number // per-page sequence; used with id for DOM scroll target
bbox: BoundingBox
highlightColor?: string
}
const highlightDataList: HighlightDefinition[] = [
{
id: 'highlight-1',
pageIndex: 0,
index: 0,
bbox: { x: 70, y: 70, width: 470, height: 50 },
highlightColor: 'rgba(255, 179, 0, 0.5)',
},
{
id: 'highlight-2',
pageIndex: 0,
index: 1,
bbox: { x: 45, y: 330, width: 255, height: 100 },
highlightColor: 'rgba(0, 255, 255, 0.5)',
},
{
id: 'highlight-3',
pageIndex: 0,
index: 2,
bbox: { x: 310, y: 684, width: 250, height: 45 },
highlightColor: 'rgba(0, 0, 255, 0.5)',
},
{
id: 'highlight-4',
pageIndex: 1,
index: 0,
bbox: { x: 310, y: 142, width: 255, height: 30 },
highlightColor: 'rgba(255, 0, 255, 0.5)',
},
]
// Highlights for one page; called from pageOverlay for each rendered page.
function getHighlightsForPage(pageIndex: number): HighlightDefinition[] {
return highlightDataList.filter((h) => h.pageIndex === pageIndex)
}
const DEFAULT_HIGHLIGHT_COLOR = 'rgba(255, 165, 0, 0.5)'
// Map PDF-point bbox to CSS pixels for the current zoom (`scale` from pageOverlay).
function getBboxHighlightStyle(
bbox: BoundingBox,
scale: number,
highlightColor?: string,
): CSSProperties {
return {
position: 'absolute',
left: `${bbox.x * scale}px`,
top: `${bbox.y * scale}px`,
width: `${bbox.width * scale}px`,
height: `${bbox.height * scale}px`,
backgroundColor: highlightColor ?? DEFAULT_HIGHLIGHT_COLOR,
// Overlays must not steal clicks from the PDF canvas underneath.
pointerEvents: 'none',
}
}
type ViewerInstance = InstanceType<typeof VPdfViewer>
// Jump to a highlight: change page, wait for overlay DOM, then
// scroll the bbox into view.
function useHighlightNavigation(viewerRef: Ref<ViewerInstance | null>) {
async function navigateToHighlight(highlight: HighlightDefinition): Promise<void> {
const viewer = viewerRef.value
if (!viewer) return
// pageControl uses 1-based page numbers. highlight.pageIndex is 0-based.
viewer.pageControl.goToPage(highlight.pageIndex + 1)
// Let Vue re-render pageOverlay for the new page before we query the DOM.
await nextTick()
// One frame after paint so layout/scroll containers have settled (zoom, page height).
requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(
// data-* attrs are set on each .bbox-highlight in #pageOverlay (see template).
// CSS.escape keeps ids with special characters safe in the selector.
`[data-highlight-id="${CSS.escape(highlight.id)}"][data-highlight-index="${highlight.index}"]`,
)
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
}
return { navigateToHighlight }
}
// Sidebar is inlined here so the example doc can show one self-contained App.vue file.
function isUnavailable(id: string): boolean {
return invalidHighlightIds.value.has(id)
}
const pdfFileSource =
'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
const viewerRef = ref<ViewerInstance | null>(null)
// Highlights whose pageIndex is out of range after load (sidebar shows disabled).
const invalidHighlightIds = ref<Set<string>>(new Set())
const activeHighlightId = ref<string | null>(null)
const { navigateToHighlight } = useHighlightNavigation(viewerRef)
// After PDF metadata is ready, mark highlights that reference non-existent pages.
function onPdfLoaded() {
const totalPages = viewerRef.value?.pageControl.totalPages ?? 0
const invalid = new Set<string>()
for (const highlight of highlightDataList) {
if (highlight.pageIndex >= totalPages) {
invalid.add(highlight.id)
}
}
invalidHighlightIds.value = invalid
}
async function onSelectHighlight(highlight: HighlightDefinition) {
if (invalidHighlightIds.value.has(highlight.id)) return
activeHighlightId.value = highlight.id
await navigateToHighlight(highlight)
}
</script>
<template>
<h1>Jump to the Next Bbox Highlight Area</h1>
<div class="app-layout">
<aside class="highlight-sidebar">
<p v-if="highlightDataList.length === 0" class="highlight-sidebar__empty">
No highlights configured.
</p>
<ol v-else class="highlight-sidebar__list">
<li
v-for="highlight in highlightDataList"
:key="highlight.id"
class="highlight-sidebar__item"
>
<button
type="button"
class="highlight-sidebar__row"
:class="{
'highlight-sidebar__row--active': activeHighlightId === highlight.id,
'highlight-sidebar__row--disabled': isUnavailable(highlight.id),
}"
:disabled="isUnavailable(highlight.id)"
:title="isUnavailable(highlight.id) ? 'Page not available' : undefined"
@click="onSelectHighlight(highlight)"
>
<div class="highlight-sidebar__page">page: {{ highlight.pageIndex + 1 }}</div>
<span class="highlight-sidebar__id">id: {{ highlight.id }}</span>
<div class="highlight-sidebar__bbox">
bbox: x:{{ highlight.bbox.x }}, y:{{ highlight.bbox.y }}, w:{{
highlight.bbox.width
}}, h:{{ highlight.bbox.height }}
</div>
<p v-if="isUnavailable(highlight.id)" class="highlight-sidebar__unavailable">
Page not available
</p>
</button>
</li>
</ol>
</aside>
<div class="viewer-panel">
<!-- pageOverlay runs once per visible page; only render highlights for that pageIndex. -->
<VPdfViewer ref="viewerRef" :src="pdfFileSource" @loaded="onPdfLoaded">
<template #pageOverlay="{ pageIndex, scale }">
<!-- data-* attributes pair with navigateToHighlight's querySelector. -->
<div
v-for="highlight in getHighlightsForPage(pageIndex)"
:key="highlight.id"
class="bbox-highlight"
:data-highlight-id="highlight.id"
:data-highlight-index="highlight.index"
:style="getBboxHighlightStyle(highlight.bbox, scale, highlight.highlightColor)"
/>
</template>
</VPdfViewer>
</div>
</div>
</template>
<style scoped>
h1 {
text-align: center;
margin-bottom: 20px;
}
.app-layout {
--sidebar-border: rgba(100, 100, 100, 0.35);
display: flex;
flex-direction: row;
min-height: 600px;
width: 100%;
}
.viewer-panel {
flex: 1;
min-width: 0;
height: 700px;
}
.bbox-highlight {
box-sizing: border-box;
}
.highlight-sidebar {
width: 240px;
flex-shrink: 0;
padding: 8px;
overflow: auto;
border-right: 1px solid var(--sidebar-border, rgba(100, 100, 100, 0.35));
}
.highlight-sidebar__empty {
margin: 12px 0;
font-size: 12px;
color: var(--sidebar-muted, #666);
}
.highlight-sidebar__list {
list-style: none;
padding: 0;
margin: 0;
}
.highlight-sidebar__item {
margin-top: 12px;
}
.highlight-sidebar__row {
display: block;
width: 100%;
padding: 5px;
font-size: 12px;
text-align: left;
cursor: pointer;
pointer-events: auto;
background: rgba(100, 100, 100, 0.2);
border: none;
border-radius: 4px;
transition: background 0.2s ease;
}
.highlight-sidebar__row:hover:not(:disabled) {
background: rgba(100, 100, 100, 0.35);
}
.highlight-sidebar__row--active {
background: rgba(100, 100, 100, 0.45);
}
.highlight-sidebar__row--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.highlight-sidebar__page {
padding-right: 5px;
}
.highlight-sidebar__unavailable {
margin: 4px 0 0;
font-size: 11px;
color: #c62828;
}
</style>vue
<script setup>
import { nextTick, ref } from 'vue'
import { VPdfViewer } from '@vue-pdf-viewer/viewer'
// Bbox values are in PDF page space (points), origin top-left of the page.
// They are multiplied by `scale` from the pageOverlay slot when rendered.
const highlightDataList = [
{
id: 'highlight-1',
pageIndex: 0,
index: 0,
bbox: { x: 70, y: 70, width: 470, height: 50 },
highlightColor: 'rgba(255, 179, 0, 0.5)',
},
{
id: 'highlight-2',
pageIndex: 0,
index: 1,
bbox: { x: 45, y: 330, width: 255, height: 100 },
highlightColor: 'rgba(0, 255, 255, 0.5)',
},
{
id: 'highlight-3',
pageIndex: 0,
index: 2,
bbox: { x: 310, y: 684, width: 250, height: 45 },
highlightColor: 'rgba(0, 0, 255, 0.5)',
},
{
id: 'highlight-4',
pageIndex: 1,
index: 0,
bbox: { x: 310, y: 142, width: 255, height: 30 },
highlightColor: 'rgba(255, 0, 255, 0.5)',
},
]
// Highlights for one page; called from pageOverlay for each rendered page.
function getHighlightsForPage(pageIndex) {
return highlightDataList.filter((h) => h.pageIndex === pageIndex)
}
const DEFAULT_HIGHLIGHT_COLOR = 'rgba(255, 165, 0, 0.5)'
// Map PDF-point bbox to CSS pixels for the current zoom (`scale` from pageOverlay).
function getBboxHighlightStyle(bbox, scale, highlightColor) {
return {
position: 'absolute',
left: `${bbox.x * scale}px`,
top: `${bbox.y * scale}px`,
width: `${bbox.width * scale}px`,
height: `${bbox.height * scale}px`,
backgroundColor: highlightColor ?? DEFAULT_HIGHLIGHT_COLOR,
// Overlays must not steal clicks from the PDF canvas underneath.
pointerEvents: 'none',
}
}
// Jump to a highlight: change page, wait for overlay DOM, then
// scroll the bbox into view.
function useHighlightNavigation(viewerRef) {
async function navigateToHighlight(highlight) {
const viewer = viewerRef.value
if (!viewer) return
// pageControl uses 1-based page numbers. highlight.pageIndex is 0-based.
viewer.pageControl.goToPage(highlight.pageIndex + 1)
// Let Vue re-render pageOverlay for the new page before we query the DOM.
await nextTick()
// One frame after paint so layout/scroll containers have settled (zoom, page height).
requestAnimationFrame(() => {
const el = document.querySelector(
// data-* attrs are set on each .bbox-highlight in #pageOverlay (see template).
// CSS.escape keeps ids with special characters safe in the selector.
`[data-highlight-id="${CSS.escape(highlight.id)}"][data-highlight-index="${highlight.index}"]`,
)
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
}
return { navigateToHighlight }
}
// Sidebar is inlined here so the example doc can show one self-contained App.vue file.
function isUnavailable(id) {
return invalidHighlightIds.value.has(id)
}
const pdfFileSource =
'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
const viewerRef = ref(null)
// Highlights whose pageIndex is out of range after load (sidebar shows disabled).
const invalidHighlightIds = ref(new Set())
const activeHighlightId = ref(null)
const { navigateToHighlight } = useHighlightNavigation(viewerRef)
// After PDF metadata is ready, mark highlights that reference non-existent pages.
function onPdfLoaded() {
const totalPages = viewerRef.value?.pageControl.totalPages ?? 0
const invalid = new Set()
for (const highlight of highlightDataList) {
if (highlight.pageIndex >= totalPages) {
invalid.add(highlight.id)
}
}
invalidHighlightIds.value = invalid
}
async function onSelectHighlight(highlight) {
if (invalidHighlightIds.value.has(highlight.id)) return
activeHighlightId.value = highlight.id
await navigateToHighlight(highlight)
}
</script>
<template>
<h1>Jump to the Next Bbox Highlight Area</h1>
<div class="app-layout">
<aside class="highlight-sidebar">
<p v-if="highlightDataList.length === 0" class="highlight-sidebar__empty">
No highlights configured.
</p>
<ol v-else class="highlight-sidebar__list">
<li
v-for="highlight in highlightDataList"
:key="highlight.id"
class="highlight-sidebar__item"
>
<button
type="button"
class="highlight-sidebar__row"
:class="{
'highlight-sidebar__row--active': activeHighlightId === highlight.id,
'highlight-sidebar__row--disabled': isUnavailable(highlight.id),
}"
:disabled="isUnavailable(highlight.id)"
:title="isUnavailable(highlight.id) ? 'Page not available' : undefined"
@click="onSelectHighlight(highlight)"
>
<div class="highlight-sidebar__page">page: {{ highlight.pageIndex + 1 }}</div>
<span class="highlight-sidebar__id">id: {{ highlight.id }}</span>
<div class="highlight-sidebar__bbox">
bbox: x:{{ highlight.bbox.x }}, y:{{ highlight.bbox.y }}, w:{{
highlight.bbox.width
}}, h:{{ highlight.bbox.height }}
</div>
<p v-if="isUnavailable(highlight.id)" class="highlight-sidebar__unavailable">
Page not available
</p>
</button>
</li>
</ol>
</aside>
<div class="viewer-panel">
<!-- pageOverlay runs once per visible page; only render highlights for that pageIndex. -->
<VPdfViewer ref="viewerRef" :src="pdfFileSource" @loaded="onPdfLoaded">
<template #pageOverlay="{ pageIndex, scale }">
<!-- data-* attributes pair with navigateToHighlight's querySelector. -->
<div
v-for="highlight in getHighlightsForPage(pageIndex)"
:key="highlight.id"
class="bbox-highlight"
:data-highlight-id="highlight.id"
:data-highlight-index="highlight.index"
:style="getBboxHighlightStyle(highlight.bbox, scale, highlight.highlightColor)"
/>
</template>
</VPdfViewer>
</div>
</div>
</template>
<style scoped>
h1 {
text-align: center;
margin-bottom: 20px;
}
.app-layout {
--sidebar-border: rgba(100, 100, 100, 0.35);
display: flex;
flex-direction: row;
min-height: 600px;
width: 100%;
}
.viewer-panel {
flex: 1;
min-width: 0;
height: 700px;
}
.bbox-highlight {
box-sizing: border-box;
}
.highlight-sidebar {
width: 240px;
flex-shrink: 0;
padding: 8px;
overflow: auto;
border-right: 1px solid var(--sidebar-border, rgba(100, 100, 100, 0.35));
}
.highlight-sidebar__empty {
margin: 12px 0;
font-size: 12px;
color: var(--sidebar-muted, #666);
}
.highlight-sidebar__list {
list-style: none;
padding: 0;
margin: 0;
}
.highlight-sidebar__item {
margin-top: 12px;
}
.highlight-sidebar__row {
display: block;
width: 100%;
padding: 5px;
font-size: 12px;
text-align: left;
cursor: pointer;
pointer-events: auto;
background: rgba(100, 100, 100, 0.2);
border: none;
border-radius: 4px;
transition: background 0.2s ease;
}
.highlight-sidebar__row:hover:not(:disabled) {
background: rgba(100, 100, 100, 0.35);
}
.highlight-sidebar__row--active {
background: rgba(100, 100, 100, 0.45);
}
.highlight-sidebar__row--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.highlight-sidebar__page {
padding-right: 5px;
}
.highlight-sidebar__unavailable {
margin: 4px 0 0;
font-size: 11px;
color: #c62828;
}
</style>vue
<script lang="ts">
import { defineComponent, nextTick, ref, type CSSProperties, type Ref } from 'vue'
import { VPdfViewer } from '@vue-pdf-viewer/viewer'
interface BoundingBox {
x: number
y: number
width: number
height: number
}
interface HighlightDefinition {
id: string
pageIndex: number
index: number
bbox: BoundingBox
highlightColor?: string
}
const highlightDataList: HighlightDefinition[] = [
{
id: 'highlight-1',
pageIndex: 0,
index: 0,
bbox: { x: 70, y: 70, width: 470, height: 50 },
highlightColor: 'rgba(255, 179, 0, 0.5)',
},
{
id: 'highlight-2',
pageIndex: 0,
index: 1,
bbox: { x: 45, y: 330, width: 255, height: 100 },
highlightColor: 'rgba(0, 255, 255, 0.5)',
},
{
id: 'highlight-3',
pageIndex: 0,
index: 2,
bbox: { x: 310, y: 684, width: 250, height: 45 },
highlightColor: 'rgba(0, 0, 255, 0.5)',
},
{
id: 'highlight-4',
pageIndex: 1,
index: 0,
bbox: { x: 310, y: 142, width: 255, height: 30 },
highlightColor: 'rgba(255, 0, 255, 0.5)',
},
]
const DEFAULT_HIGHLIGHT_COLOR = 'rgba(255, 165, 0, 0.5)'
type ViewerInstance = InstanceType<typeof VPdfViewer>
function getHighlightsForPage(pageIndex: number): HighlightDefinition[] {
return highlightDataList.filter((h) => h.pageIndex === pageIndex)
}
function getBboxHighlightStyle(
bbox: BoundingBox,
scale: number,
highlightColor?: string,
): CSSProperties {
return {
position: 'absolute',
left: `${bbox.x * scale}px`,
top: `${bbox.y * scale}px`,
width: `${bbox.width * scale}px`,
height: `${bbox.height * scale}px`,
backgroundColor: highlightColor ?? DEFAULT_HIGHLIGHT_COLOR,
pointerEvents: 'none',
}
}
function useHighlightNavigation(viewerRef: Ref<ViewerInstance | null>) {
async function navigateToHighlight(highlight: HighlightDefinition): Promise<void> {
const viewer = viewerRef.value
if (!viewer) return
viewer.pageControl.goToPage(highlight.pageIndex + 1)
await nextTick()
requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(
`[data-highlight-id="${CSS.escape(highlight.id)}"][data-highlight-index="${highlight.index}"]`,
)
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
}
return { navigateToHighlight }
}
export default defineComponent({
components: { VPdfViewer },
setup() {
const pdfFileSource =
'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
const viewerRef = ref<ViewerInstance | null>(null)
const invalidHighlightIds = ref<Set<string>>(new Set())
const activeHighlightId = ref<string | null>(null)
const { navigateToHighlight } = useHighlightNavigation(viewerRef)
function isUnavailable(id: string): boolean {
return invalidHighlightIds.value.has(id)
}
function onPdfLoaded() {
const totalPages = viewerRef.value?.pageControl.totalPages ?? 0
const invalid = new Set<string>()
for (const highlight of highlightDataList) {
if (highlight.pageIndex >= totalPages) {
invalid.add(highlight.id)
}
}
invalidHighlightIds.value = invalid
}
async function onSelectHighlight(highlight: HighlightDefinition) {
if (invalidHighlightIds.value.has(highlight.id)) return
activeHighlightId.value = highlight.id
await navigateToHighlight(highlight)
}
return {
highlightDataList,
getHighlightsForPage,
getBboxHighlightStyle,
isUnavailable,
pdfFileSource,
viewerRef,
activeHighlightId,
onPdfLoaded,
onSelectHighlight,
}
},
})
</script>
<template>
<h1>Jump to the Next Bbox Highlight Area</h1>
<div class="app-layout">
<aside class="highlight-sidebar">
<p v-if="highlightDataList.length === 0" class="highlight-sidebar__empty">
No highlights configured.
</p>
<ol v-else class="highlight-sidebar__list">
<li
v-for="highlight in highlightDataList"
:key="highlight.id"
class="highlight-sidebar__item"
>
<button
type="button"
class="highlight-sidebar__row"
:class="{
'highlight-sidebar__row--active': activeHighlightId === highlight.id,
'highlight-sidebar__row--disabled': isUnavailable(highlight.id),
}"
:disabled="isUnavailable(highlight.id)"
:title="isUnavailable(highlight.id) ? 'Page not available' : undefined"
@click="onSelectHighlight(highlight)"
>
<div class="highlight-sidebar__page">page: {{ highlight.pageIndex + 1 }}</div>
<span class="highlight-sidebar__id">id: {{ highlight.id }}</span>
<div class="highlight-sidebar__bbox">
bbox: x:{{ highlight.bbox.x }}, y:{{ highlight.bbox.y }}, w:{{
highlight.bbox.width
}}, h:{{ highlight.bbox.height }}
</div>
<p v-if="isUnavailable(highlight.id)" class="highlight-sidebar__unavailable">
Page not available
</p>
</button>
</li>
</ol>
</aside>
<div class="viewer-panel">
<VPdfViewer ref="viewerRef" :src="pdfFileSource" @loaded="onPdfLoaded">
<template #pageOverlay="{ pageIndex, scale }">
<div
v-for="highlight in getHighlightsForPage(pageIndex)"
:key="highlight.id"
class="bbox-highlight"
:data-highlight-id="highlight.id"
:data-highlight-index="highlight.index"
:style="getBboxHighlightStyle(highlight.bbox, scale, highlight.highlightColor)"
/>
</template>
</VPdfViewer>
</div>
</div>
</template>
<style scoped>
h1 {
text-align: center;
margin-bottom: 20px;
}
.app-layout {
--sidebar-border: rgba(100, 100, 100, 0.35);
display: flex;
flex-direction: row;
min-height: 600px;
width: 100%;
}
.viewer-panel {
flex: 1;
min-width: 0;
height: 700px;
}
.bbox-highlight {
box-sizing: border-box;
}
.highlight-sidebar {
width: 240px;
flex-shrink: 0;
padding: 8px;
overflow: auto;
border-right: 1px solid var(--sidebar-border, rgba(100, 100, 100, 0.35));
}
.highlight-sidebar__empty {
margin: 12px 0;
font-size: 12px;
color: var(--sidebar-muted, #666);
}
.highlight-sidebar__list {
list-style: none;
padding: 0;
margin: 0;
}
.highlight-sidebar__item {
margin-top: 12px;
}
.highlight-sidebar__row {
display: block;
width: 100%;
padding: 5px;
font-size: 12px;
text-align: left;
cursor: pointer;
pointer-events: auto;
background: rgba(100, 100, 100, 0.2);
border: none;
border-radius: 4px;
transition: background 0.2s ease;
}
.highlight-sidebar__row:hover:not(:disabled) {
background: rgba(100, 100, 100, 0.35);
}
.highlight-sidebar__row--active {
background: rgba(100, 100, 100, 0.45);
}
.highlight-sidebar__row--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.highlight-sidebar__page {
padding-right: 5px;
}
.highlight-sidebar__unavailable {
margin: 4px 0 0;
font-size: 11px;
color: #c62828;
}
</style>vue
<script>
import { defineComponent, nextTick, ref } from 'vue'
import { VPdfViewer } from '@vue-pdf-viewer/viewer'
const highlightDataList = [
{
id: 'highlight-1',
pageIndex: 0,
index: 0,
bbox: { x: 70, y: 70, width: 470, height: 50 },
highlightColor: 'rgba(255, 179, 0, 0.5)',
},
{
id: 'highlight-2',
pageIndex: 0,
index: 1,
bbox: { x: 45, y: 330, width: 255, height: 100 },
highlightColor: 'rgba(0, 255, 255, 0.5)',
},
{
id: 'highlight-3',
pageIndex: 0,
index: 2,
bbox: { x: 310, y: 684, width: 250, height: 45 },
highlightColor: 'rgba(0, 0, 255, 0.5)',
},
{
id: 'highlight-4',
pageIndex: 1,
index: 0,
bbox: { x: 310, y: 142, width: 255, height: 30 },
highlightColor: 'rgba(255, 0, 255, 0.5)',
},
]
const DEFAULT_HIGHLIGHT_COLOR = 'rgba(255, 165, 0, 0.5)'
function getHighlightsForPage(pageIndex) {
return highlightDataList.filter((h) => h.pageIndex === pageIndex)
}
function getBboxHighlightStyle(bbox, scale, highlightColor) {
return {
position: 'absolute',
left: `${bbox.x * scale}px`,
top: `${bbox.y * scale}px`,
width: `${bbox.width * scale}px`,
height: `${bbox.height * scale}px`,
backgroundColor: highlightColor ?? DEFAULT_HIGHLIGHT_COLOR,
pointerEvents: 'none',
}
}
function useHighlightNavigation(viewerRef) {
async function navigateToHighlight(highlight) {
const viewer = viewerRef.value
if (!viewer) return
viewer.pageControl.goToPage(highlight.pageIndex + 1)
await nextTick()
requestAnimationFrame(() => {
const el = document.querySelector(
`[data-highlight-id="${CSS.escape(highlight.id)}"][data-highlight-index="${highlight.index}"]`,
)
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
}
return { navigateToHighlight }
}
export default defineComponent({
components: { VPdfViewer },
setup() {
const pdfFileSource =
'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
const viewerRef = ref(null)
const invalidHighlightIds = ref(new Set())
const activeHighlightId = ref(null)
const { navigateToHighlight } = useHighlightNavigation(viewerRef)
function isUnavailable(id) {
return invalidHighlightIds.value.has(id)
}
function onPdfLoaded() {
const totalPages = viewerRef.value?.pageControl.totalPages ?? 0
const invalid = new Set()
for (const highlight of highlightDataList) {
if (highlight.pageIndex >= totalPages) {
invalid.add(highlight.id)
}
}
invalidHighlightIds.value = invalid
}
async function onSelectHighlight(highlight) {
if (invalidHighlightIds.value.has(highlight.id)) return
activeHighlightId.value = highlight.id
await navigateToHighlight(highlight)
}
return {
highlightDataList,
getHighlightsForPage,
getBboxHighlightStyle,
isUnavailable,
pdfFileSource,
viewerRef,
activeHighlightId,
onPdfLoaded,
onSelectHighlight,
}
},
})
</script>
<template>
<h1>Jump to the Next Bbox Highlight Area</h1>
<div class="app-layout">
<aside class="highlight-sidebar">
<p v-if="highlightDataList.length === 0" class="highlight-sidebar__empty">
No highlights configured.
</p>
<ol v-else class="highlight-sidebar__list">
<li
v-for="highlight in highlightDataList"
:key="highlight.id"
class="highlight-sidebar__item"
>
<button
type="button"
class="highlight-sidebar__row"
:class="{
'highlight-sidebar__row--active': activeHighlightId === highlight.id,
'highlight-sidebar__row--disabled': isUnavailable(highlight.id),
}"
:disabled="isUnavailable(highlight.id)"
:title="isUnavailable(highlight.id) ? 'Page not available' : undefined"
@click="onSelectHighlight(highlight)"
>
<div class="highlight-sidebar__page">page: {{ highlight.pageIndex + 1 }}</div>
<span class="highlight-sidebar__id">id: {{ highlight.id }}</span>
<div class="highlight-sidebar__bbox">
bbox: x:{{ highlight.bbox.x }}, y:{{ highlight.bbox.y }}, w:{{
highlight.bbox.width
}}, h:{{ highlight.bbox.height }}
</div>
<p v-if="isUnavailable(highlight.id)" class="highlight-sidebar__unavailable">
Page not available
</p>
</button>
</li>
</ol>
</aside>
<div class="viewer-panel">
<VPdfViewer ref="viewerRef" :src="pdfFileSource" @loaded="onPdfLoaded">
<template #pageOverlay="{ pageIndex, scale }">
<div
v-for="highlight in getHighlightsForPage(pageIndex)"
:key="highlight.id"
class="bbox-highlight"
:data-highlight-id="highlight.id"
:data-highlight-index="highlight.index"
:style="getBboxHighlightStyle(highlight.bbox, scale, highlight.highlightColor)"
/>
</template>
</VPdfViewer>
</div>
</div>
</template>
<style scoped>
h1 {
text-align: center;
margin-bottom: 20px;
}
.app-layout {
--sidebar-border: rgba(100, 100, 100, 0.35);
display: flex;
flex-direction: row;
min-height: 600px;
width: 100%;
}
.viewer-panel {
flex: 1;
min-width: 0;
height: 700px;
}
.bbox-highlight {
box-sizing: border-box;
}
.highlight-sidebar {
width: 240px;
flex-shrink: 0;
padding: 8px;
overflow: auto;
border-right: 1px solid var(--sidebar-border, rgba(100, 100, 100, 0.35));
}
.highlight-sidebar__empty {
margin: 12px 0;
font-size: 12px;
color: var(--sidebar-muted, #666);
}
.highlight-sidebar__list {
list-style: none;
padding: 0;
margin: 0;
}
.highlight-sidebar__item {
margin-top: 12px;
}
.highlight-sidebar__row {
display: block;
width: 100%;
padding: 5px;
font-size: 12px;
text-align: left;
cursor: pointer;
pointer-events: auto;
background: rgba(100, 100, 100, 0.2);
border: none;
border-radius: 4px;
transition: background 0.2s ease;
}
.highlight-sidebar__row:hover:not(:disabled) {
background: rgba(100, 100, 100, 0.35);
}
.highlight-sidebar__row--active {
background: rgba(100, 100, 100, 0.45);
}
.highlight-sidebar__row--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.highlight-sidebar__page {
padding-right: 5px;
}
.highlight-sidebar__unavailable {
margin: 4px 0 0;
font-size: 11px;
color: #c62828;
}
</style>Notes
goToPageuses 1-based page numbers: Add1to the zero-basedpageIndexwhen callingpageControl.goToPage(highlight.pageIndex + 1).pageIndexandindexare zero-based:pageIndexis the page in the highlight catalog.indexis the highlight’s order on that page. Use both indata-highlight-idanddata-highlight-indexsoscrollIntoViewcan target the correct overlay when multiple highlights share a page.- Scale: Multiply every bbox coordinate (
x,y,width,height) by the slotscalevalue so overlays stay aligned at any zoom level. - Pointer events: Set
pointerEvents: 'none'on highlight rectangles so users can still select text and interact with the PDF underneath. - Invalid pages: On the
@loadedevent, compare each highlight’spageIndextototalPagesand disable sidebar entries that reference missing pages. - Remote PDF: The sample uses an HTTPS URL. Ensure the host allows cross-origin fetch if you load PDFs from another domain.