Add a Custom Action to a Highlighted Element
Scenario
You want to highlight important phrases in a PDF programmaticaly and allow users to take action directly from each highlighted element in a Vue PDF Component.
In this example:
- A long title is highlighted programmatically.
Copycopies the highlighted text and shows a small confirmation banner.Explainsends the highlighted text to a text box on the right.
What to Use
Use the highlightControl API from VPdfViewer to create keyword highlights. After the highlights are rendered, attach one delegated click listener to the viewer.
| API | Objective |
|---|---|
highlightControl.highlight | Programmatically highlight keywords or regular expression matches. |
After intergrating the highlightControl API, you may add a copy function with the Clipboard API from the browser.
| API | Objective |
|---|---|
| Clipboard API | Copy the highlighted text to the user's clipboard. |
Complete Example
Here is a complete example of how you can highlight a long text string, click a rendered highlight element, and choose Copy or Explain from the popover.
vue
<script setup lang="ts">
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
const PDF_FILE =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const vpvRef = ref<InstanceType<typeof VPdfViewer>>();
const highlightControl = computed(() => vpvRef.value?.highlightControl);
const showPopover = ref(false);
const popoverPosition = ref({ x: 0, y: 0 });
const selectedHighlightText = ref("");
const explainedText = ref("");
const copied = ref(false);
let copiedTimer: ReturnType<typeof window.setTimeout> | undefined;
// Each programmatic highlight is rendered as a `.vpv-highlight__text` element.
const HIGHLIGHT_TEXT_SELECTOR =
".vpv-custom-highlight-layer-wrapper .vpv-highlight__text";
const highlightKeywords = async () => {
await highlightControl.value?.highlight([
{
keyword:
"Trace-based Just-in-Time Type Specialization for Dynamic",
highlightColor: "rgba(74, 222, 128, 0.45)",
},
{
keyword: "Dynamic languages such as JavaScript are more difficult",
highlightColor: "rgba(0, 245, 255, 0.5)",
},
]);
// Wait until Vue and the viewer finish rendering the highlight DOM nodes.
await nextTick();
window.setTimeout(prepareHighlightElements, 100);
};
const prepareHighlightElements = () => {
const viewerElement = vpvRef.value?.$el as HTMLElement | undefined;
const highlightElements = viewerElement?.querySelectorAll<HTMLElement>(
HIGHLIGHT_TEXT_SELECTOR
);
// Make the rendered highlight spans behave like accessible action targets.
highlightElements?.forEach((element) => {
element.classList.add("highlight-action-target");
element.setAttribute("role", "button");
element.setAttribute("tabindex", "0");
element.setAttribute(
"aria-label",
`Open actions for ${getHighlightText(element)}`
);
});
};
const getHighlightText = (element: HTMLElement) => {
return element.getAttribute("title")?.trim() || "Highlighted text";
};
const openPopover = (element: HTMLElement) => {
const bounds = element.getBoundingClientRect();
selectedHighlightText.value = getHighlightText(element);
popoverPosition.value = {
x: bounds.left + bounds.width / 2,
y: bounds.bottom + 8,
};
showPopover.value = true;
};
const getClickedHighlightElement = (event: MouseEvent) => {
// Prefer the clicked highlight element when the browser reports it directly.
const highlightElement = (event.target as HTMLElement).closest<HTMLElement>(
HIGHLIGHT_TEXT_SELECTOR
);
if (highlightElement) return highlightElement;
const viewerElement = vpvRef.value?.$el as HTMLElement | undefined;
const highlightElements = viewerElement?.querySelectorAll<HTMLElement>(
HIGHLIGHT_TEXT_SELECTOR
);
// Some PDF layers may sit above highlights
return Array.from(highlightElements ?? []).find((element) => {
const bounds = element.getBoundingClientRect();
return (
event.clientX >= bounds.left &&
event.clientX <= bounds.right &&
event.clientY >= bounds.top &&
event.clientY <= bounds.bottom
);
});
};
const handleHighlightClick = (event: MouseEvent) => {
const highlightElement = getClickedHighlightElement(event);
if (!highlightElement) return;
event.preventDefault();
event.stopPropagation();
openPopover(highlightElement);
};
const handleHighlightKeydown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
const highlightElement = (event.target as HTMLElement).closest<HTMLElement>(
HIGHLIGHT_TEXT_SELECTOR
);
if (!highlightElement) return;
event.preventDefault();
openPopover(highlightElement);
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (
target.closest(".highlight-popover") ||
target.closest(HIGHLIGHT_TEXT_SELECTOR)
) {
return;
}
showPopover.value = false;
};
const handleCopy = async () => {
if (!selectedHighlightText.value) return;
await navigator.clipboard.writeText(selectedHighlightText.value);
showPopover.value = false;
copied.value = true;
window.clearTimeout(copiedTimer);
copiedTimer = window.setTimeout(() => {
copied.value = false;
}, 1600);
};
const handleExplain = () => {
explainedText.value = selectedHighlightText.value;
showPopover.value = false;
};
watch(
() => vpvRef.value?.$el as HTMLElement | undefined,
(viewerElement, _, onCleanup) => {
if (!viewerElement) return;
// Use delegated listeners because highlight nodes can be re-rendered by the viewer.
viewerElement.addEventListener("click", handleHighlightClick);
viewerElement.addEventListener("keydown", handleHighlightKeydown);
document.addEventListener("click", handleDocumentClick);
// Highlight elements are rendered after the PDF page content is ready.
highlightKeywords();
onCleanup(() => {
viewerElement.removeEventListener("click", handleHighlightClick);
viewerElement.removeEventListener("keydown", handleHighlightKeydown);
document.removeEventListener("click", handleDocumentClick);
});
},
{ flush: "post" }
);
onBeforeUnmount(() => {
window.clearTimeout(copiedTimer);
});
</script>
<template>
<div class="highlight-example">
<div class="viewer-panel">
<VPdfViewer ref="vpvRef" :src="PDF_FILE" />
</div>
<aside class="explain-panel" aria-label="Explain highlighted text">
<form class="explain-form" @submit.prevent>
<textarea
v-model="explainedText"
class="explain-textarea"
aria-label="Highlighted text to explain"
placeholder="Click a highlighted phrase, then choose Explain."
></textarea>
<button type="submit">Submit</button>
</form>
</aside>
<div
v-if="showPopover"
class="highlight-popover"
:style="{
top: `${popoverPosition.y}px`,
left: `${popoverPosition.x}px`,
}"
>
<button type="button" @click="handleCopy">Copy</button>
<button type="button" @click="handleExplain">Explain</button>
</div>
<div v-if="copied" class="copied-banner">Text copied</div>
</div>
</template>
<style scoped>
.highlight-example {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
padding: 8px;
min-height: 700px;
}
.viewer-panel {
min-width: 0;
height: 700px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.explain-panel {
display: flex;
align-items: flex-end;
min-height: 700px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
color: #111827;
overflow: hidden;
}
.explain-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
width: 100%;
border-top: 1px solid #d1d5db;
}
.explain-textarea {
min-height: 72px;
padding: 8px;
font: inherit;
line-height: 1.5;
color: #111827;
resize: vertical;
border: 0;
border-right: 1px solid #d1d5db;
outline: none;
}
.explain-textarea:focus {
box-shadow: inset 0 0 0 2px #2563eb;
}
.explain-form button {
font: inherit;
font-weight: 600;
color: #111827;
cursor: pointer;
background: #ffffff;
border: 0;
}
.explain-form button:hover,
.explain-form button:focus-visible {
background: #f3f4f6;
outline: none;
box-shadow: inset 0 0 0 2px #2563eb;
}
.highlight-popover {
position: fixed;
z-index: 1000;
display: flex;
gap: 4px;
padding: 4px;
background: #111827;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
transform: translateX(-50%);
}
.highlight-popover button {
padding: 8px 12px;
color: #ffffff;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 6px;
}
.highlight-popover button:hover,
.highlight-popover button:focus-visible {
background: rgba(255, 255, 255, 0.14);
}
.copied-banner {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 1000;
padding: 10px 14px;
color: #ffffff;
background: #111827;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
}
:deep(.vpv-custom-highlight-layer-wrapper) {
/* Let the page keep normal PDF interactions except on the highlight text. */
pointer-events: none;
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text),
:deep(.highlight-action-target) {
/* Re-enable pointer events only for highlights that open the action popover. */
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 2px;
pointer-events: auto;
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text:hover),
:deep(.highlight-action-target:hover) {
box-shadow: 0 0 0 2px rgba(17, 24, 39, 0.3);
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text:focus-visible),
:deep(.highlight-action-target:focus-visible) {
outline-color: #2563eb;
}
@media (max-width: 900px) {
.highlight-example {
grid-template-columns: 1fr;
}
.viewer-panel,
.explain-panel {
min-height: auto;
height: 620px;
}
}
</style>vue
<script setup>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
const PDF_FILE =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const vpvRef = ref();
const highlightControl = computed(() => vpvRef.value?.highlightControl);
const showPopover = ref(false);
const popoverPosition = ref({ x: 0, y: 0 });
const selectedHighlightText = ref("");
const explainedText = ref("");
const copied = ref(false);
let copiedTimer;
// Each programmatic highlight is rendered as a `.vpv-highlight__text` element.
const HIGHLIGHT_TEXT_SELECTOR =
".vpv-custom-highlight-layer-wrapper .vpv-highlight__text";
const highlightKeywords = async () => {
await highlightControl.value?.highlight([
{
keyword:
"Trace-based Just-in-Time Type Specialization for Dynamic",
highlightColor: "rgba(74, 222, 128, 0.45)",
},
{
keyword: "Dynamic languages such as JavaScript are more difficult",
highlightColor: "rgba(0, 245, 255, 0.5)",
},
]);
// Wait until Vue and the viewer finish rendering the highlight DOM nodes.
await nextTick();
window.setTimeout(prepareHighlightElements, 100);
};
const prepareHighlightElements = () => {
const viewerElement = vpvRef.value?.$el;
const highlightElements = viewerElement?.querySelectorAll(
HIGHLIGHT_TEXT_SELECTOR
);
// Make the rendered highlight spans behave like accessible action targets.
highlightElements?.forEach((element) => {
element.classList.add("highlight-action-target");
element.setAttribute("role", "button");
element.setAttribute("tabindex", "0");
element.setAttribute(
"aria-label",
`Open actions for ${getHighlightText(element)}`
);
});
};
const getHighlightText = (element) => {
return element.getAttribute("title")?.trim() || "Highlighted text";
};
const openPopover = (element) => {
const bounds = element.getBoundingClientRect();
selectedHighlightText.value = getHighlightText(element);
popoverPosition.value = {
x: bounds.left + bounds.width / 2,
y: bounds.bottom + 8,
};
showPopover.value = true;
};
const getClickedHighlightElement = (event) => {
// Prefer the clicked highlight element when the browser reports it directly.
const highlightElement = event.target.closest(HIGHLIGHT_TEXT_SELECTOR);
if (highlightElement) return highlightElement;
const viewerElement = vpvRef.value?.$el;
const highlightElements = viewerElement?.querySelectorAll(
HIGHLIGHT_TEXT_SELECTOR
);
// Some PDF layers may sit above highlights
return Array.from(highlightElements ?? []).find((element) => {
const bounds = element.getBoundingClientRect();
return (
event.clientX >= bounds.left &&
event.clientX <= bounds.right &&
event.clientY >= bounds.top &&
event.clientY <= bounds.bottom
);
});
};
const handleHighlightClick = (event) => {
const highlightElement = getClickedHighlightElement(event);
if (!highlightElement) return;
event.preventDefault();
event.stopPropagation();
openPopover(highlightElement);
};
const handleHighlightKeydown = (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const highlightElement = event.target.closest(HIGHLIGHT_TEXT_SELECTOR);
if (!highlightElement) return;
event.preventDefault();
openPopover(highlightElement);
};
const handleDocumentClick = (event) => {
const target = event.target;
if (
target.closest(".highlight-popover") ||
target.closest(HIGHLIGHT_TEXT_SELECTOR)
) {
return;
}
showPopover.value = false;
};
const handleCopy = async () => {
if (!selectedHighlightText.value) return;
await navigator.clipboard.writeText(selectedHighlightText.value);
showPopover.value = false;
copied.value = true;
window.clearTimeout(copiedTimer);
copiedTimer = window.setTimeout(() => {
copied.value = false;
}, 1600);
};
const handleExplain = () => {
explainedText.value = selectedHighlightText.value;
showPopover.value = false;
};
watch(
() => vpvRef.value?.$el,
(viewerElement, _, onCleanup) => {
if (!viewerElement) return;
// Use delegated listeners because highlight nodes can be re-rendered by the viewer.
viewerElement.addEventListener("click", handleHighlightClick);
viewerElement.addEventListener("keydown", handleHighlightKeydown);
document.addEventListener("click", handleDocumentClick);
// Highlight elements are rendered after the PDF page content is ready.
highlightKeywords();
onCleanup(() => {
viewerElement.removeEventListener("click", handleHighlightClick);
viewerElement.removeEventListener("keydown", handleHighlightKeydown);
document.removeEventListener("click", handleDocumentClick);
});
},
{ flush: "post" }
);
onBeforeUnmount(() => {
window.clearTimeout(copiedTimer);
});
</script>
<template>
<div class="highlight-example">
<div class="viewer-panel">
<VPdfViewer ref="vpvRef" :src="PDF_FILE" />
</div>
<aside class="explain-panel" aria-label="Explain highlighted text">
<form class="explain-form" @submit.prevent>
<textarea
v-model="explainedText"
class="explain-textarea"
aria-label="Highlighted text to explain"
placeholder="Click a highlighted phrase, then choose Explain."
></textarea>
<button type="submit">Submit</button>
</form>
</aside>
<div
v-if="showPopover"
class="highlight-popover"
:style="{
top: `${popoverPosition.y}px`,
left: `${popoverPosition.x}px`,
}"
>
<button type="button" @click="handleCopy">Copy</button>
<button type="button" @click="handleExplain">Explain</button>
</div>
<div v-if="copied" class="copied-banner">Text copied</div>
</div>
</template>
<style scoped>
.highlight-example {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
padding: 8px;
min-height: 700px;
}
.viewer-panel {
min-width: 0;
height: 700px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.explain-panel {
display: flex;
align-items: flex-end;
min-height: 700px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
color: #111827;
overflow: hidden;
}
.explain-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
width: 100%;
border-top: 1px solid #d1d5db;
}
.explain-textarea {
min-height: 72px;
padding: 8px;
font: inherit;
line-height: 1.5;
color: #111827;
resize: vertical;
border: 0;
border-right: 1px solid #d1d5db;
outline: none;
}
.explain-textarea:focus {
box-shadow: inset 0 0 0 2px #2563eb;
}
.explain-form button {
font: inherit;
font-weight: 600;
color: #111827;
cursor: pointer;
background: #ffffff;
border: 0;
}
.explain-form button:hover,
.explain-form button:focus-visible {
background: #f3f4f6;
outline: none;
box-shadow: inset 0 0 0 2px #2563eb;
}
.highlight-popover {
position: fixed;
z-index: 1000;
display: flex;
gap: 4px;
padding: 4px;
background: #111827;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
transform: translateX(-50%);
}
.highlight-popover button {
padding: 8px 12px;
color: #ffffff;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 6px;
}
.highlight-popover button:hover,
.highlight-popover button:focus-visible {
background: rgba(255, 255, 255, 0.14);
}
.copied-banner {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 1000;
padding: 10px 14px;
color: #ffffff;
background: #111827;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
}
:deep(.vpv-custom-highlight-layer-wrapper) {
/* Let the page keep normal PDF interactions except on the highlight text. */
pointer-events: none;
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text),
:deep(.highlight-action-target) {
/* Re-enable pointer events only for highlights that open the action popover. */
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 2px;
pointer-events: auto;
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text:hover),
:deep(.highlight-action-target:hover) {
box-shadow: 0 0 0 2px rgba(17, 24, 39, 0.3);
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text:focus-visible),
:deep(.highlight-action-target:focus-visible) {
outline-color: #2563eb;
}
@media (max-width: 900px) {
.highlight-example {
grid-template-columns: 1fr;
}
.viewer-panel,
.explain-panel {
min-height: auto;
height: 620px;
}
}
</style>vue
<script lang="ts">
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
import { computed, defineComponent, nextTick, onBeforeUnmount, ref, watch } from "vue";
export default defineComponent({
components: { VPdfViewer },
setup() {
const PDF_FILE =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const vpvRef = ref<InstanceType<typeof VPdfViewer>>();
const highlightControl = computed(() => vpvRef.value?.highlightControl);
const showPopover = ref(false);
const popoverPosition = ref({ x: 0, y: 0 });
const selectedHighlightText = ref("");
const explainedText = ref("");
const copied = ref(false);
let copiedTimer: ReturnType<typeof window.setTimeout> | undefined;
// Each programmatic highlight is rendered as a `.vpv-highlight__text` element.
const HIGHLIGHT_TEXT_SELECTOR =
".vpv-custom-highlight-layer-wrapper .vpv-highlight__text";
const highlightKeywords = async () => {
await highlightControl.value?.highlight([
{
keyword:
"Trace-based Just-in-Time Type Specialization for Dynamic",
highlightColor: "rgba(74, 222, 128, 0.45)",
},
{
keyword: "Dynamic languages such as JavaScript are more difficult",
highlightColor: "rgba(0, 245, 255, 0.5)",
},
]);
// Wait until Vue and the viewer finish rendering the highlight DOM nodes.
await nextTick();
window.setTimeout(prepareHighlightElements, 100);
};
const prepareHighlightElements = () => {
const viewerElement = vpvRef.value?.$el as HTMLElement | undefined;
const highlightElements = viewerElement?.querySelectorAll<HTMLElement>(
HIGHLIGHT_TEXT_SELECTOR
);
// Make the rendered highlight spans behave like accessible action targets.
highlightElements?.forEach((element) => {
element.classList.add("highlight-action-target");
element.setAttribute("role", "button");
element.setAttribute("tabindex", "0");
element.setAttribute(
"aria-label",
`Open actions for ${getHighlightText(element)}`
);
});
};
const getHighlightText = (element: HTMLElement) => {
return element.getAttribute("title")?.trim() || "Highlighted text";
};
const openPopover = (element: HTMLElement) => {
const bounds = element.getBoundingClientRect();
selectedHighlightText.value = getHighlightText(element);
popoverPosition.value = {
x: bounds.left + bounds.width / 2,
y: bounds.bottom + 8,
};
showPopover.value = true;
};
const getClickedHighlightElement = (event: MouseEvent) => {
// Prefer the clicked highlight element when the browser reports it directly.
const highlightElement = (event.target as HTMLElement).closest<HTMLElement>(
HIGHLIGHT_TEXT_SELECTOR
);
if (highlightElement) return highlightElement;
const viewerElement = vpvRef.value?.$el as HTMLElement | undefined;
const highlightElements = viewerElement?.querySelectorAll<HTMLElement>(
HIGHLIGHT_TEXT_SELECTOR
);
// Some PDF layers may sit above highlights
return Array.from(highlightElements ?? []).find((element) => {
const bounds = element.getBoundingClientRect();
return (
event.clientX >= bounds.left &&
event.clientX <= bounds.right &&
event.clientY >= bounds.top &&
event.clientY <= bounds.bottom
);
});
};
const handleHighlightClick = (event: MouseEvent) => {
const highlightElement = getClickedHighlightElement(event);
if (!highlightElement) return;
event.preventDefault();
event.stopPropagation();
openPopover(highlightElement);
};
const handleHighlightKeydown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
const highlightElement = (event.target as HTMLElement).closest<HTMLElement>(
HIGHLIGHT_TEXT_SELECTOR
);
if (!highlightElement) return;
event.preventDefault();
openPopover(highlightElement);
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (
target.closest(".highlight-popover") ||
target.closest(HIGHLIGHT_TEXT_SELECTOR)
) {
return;
}
showPopover.value = false;
};
const handleCopy = async () => {
if (!selectedHighlightText.value) return;
await navigator.clipboard.writeText(selectedHighlightText.value);
showPopover.value = false;
copied.value = true;
window.clearTimeout(copiedTimer);
copiedTimer = window.setTimeout(() => {
copied.value = false;
}, 1600);
};
const handleExplain = () => {
explainedText.value = selectedHighlightText.value;
showPopover.value = false;
};
watch(
() => vpvRef.value?.$el as HTMLElement | undefined,
(viewerElement, _, onCleanup) => {
if (!viewerElement) return;
// Use delegated listeners because highlight nodes can be re-rendered by the viewer.
viewerElement.addEventListener("click", handleHighlightClick);
viewerElement.addEventListener("keydown", handleHighlightKeydown);
document.addEventListener("click", handleDocumentClick);
// Highlight elements are rendered after the PDF page content is ready.
highlightKeywords();
onCleanup(() => {
viewerElement.removeEventListener("click", handleHighlightClick);
viewerElement.removeEventListener("keydown", handleHighlightKeydown);
document.removeEventListener("click", handleDocumentClick);
});
},
{ flush: "post" }
);
onBeforeUnmount(() => {
window.clearTimeout(copiedTimer);
});
return {
PDF_FILE,
vpvRef,
showPopover,
popoverPosition,
selectedHighlightText,
explainedText,
copied,
handleCopy,
handleExplain,
};
},
});
</script>
<template>
<div class="highlight-example">
<div class="viewer-panel">
<VPdfViewer ref="vpvRef" :src="PDF_FILE" />
</div>
<aside class="explain-panel" aria-label="Explain highlighted text">
<form class="explain-form" @submit.prevent>
<textarea
v-model="explainedText"
class="explain-textarea"
aria-label="Highlighted text to explain"
placeholder="Click a highlighted phrase, then choose Explain."
></textarea>
<button type="submit">Submit</button>
</form>
</aside>
<div
v-if="showPopover"
class="highlight-popover"
:style="{
top: `${popoverPosition.y}px`,
left: `${popoverPosition.x}px`,
}"
>
<button type="button" @click="handleCopy">Copy</button>
<button type="button" @click="handleExplain">Explain</button>
</div>
<div v-if="copied" class="copied-banner">Text copied</div>
</div>
</template>
<style scoped>
/* Same styles as Composition TS example */
.highlight-example {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
padding: 8px;
min-height: 700px;
}
.viewer-panel {
min-width: 0;
height: 700px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.explain-panel {
display: flex;
align-items: flex-end;
min-height: 700px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
color: #111827;
overflow: hidden;
}
.explain-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
width: 100%;
border-top: 1px solid #d1d5db;
}
.explain-textarea {
min-height: 72px;
padding: 8px;
font: inherit;
line-height: 1.5;
color: #111827;
resize: vertical;
border: 0;
border-right: 1px solid #d1d5db;
outline: none;
}
.explain-textarea:focus {
box-shadow: inset 0 0 0 2px #2563eb;
}
.explain-form button {
font: inherit;
font-weight: 600;
color: #111827;
cursor: pointer;
background: #ffffff;
border: 0;
}
.explain-form button:hover,
.explain-form button:focus-visible {
background: #f3f4f6;
outline: none;
box-shadow: inset 0 0 0 2px #2563eb;
}
.highlight-popover {
position: fixed;
z-index: 1000;
display: flex;
gap: 4px;
padding: 4px;
background: #111827;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
transform: translateX(-50%);
}
.highlight-popover button {
padding: 8px 12px;
color: #ffffff;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 6px;
}
.highlight-popover button:hover,
.highlight-popover button:focus-visible {
background: rgba(255, 255, 255, 0.14);
}
.copied-banner {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 1000;
padding: 10px 14px;
color: #ffffff;
background: #111827;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
}
:deep(.vpv-custom-highlight-layer-wrapper) {
pointer-events: none;
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text),
:deep(.highlight-action-target) {
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 2px;
pointer-events: auto;
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text:hover),
:deep(.highlight-action-target:hover) {
box-shadow: 0 0 0 2px rgba(17, 24, 39, 0.3);
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text:focus-visible),
:deep(.highlight-action-target:focus-visible) {
outline-color: #2563eb;
}
@media (max-width: 900px) {
.highlight-example {
grid-template-columns: 1fr;
}
.viewer-panel,
.explain-panel {
min-height: auto;
height: 620px;
}
}
</style>vue
<script>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
export default {
components: { VPdfViewer },
setup() {
const PDF_FILE =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const vpvRef = ref();
const highlightControl = computed(() => vpvRef.value?.highlightControl);
const showPopover = ref(false);
const popoverPosition = ref({ x: 0, y: 0 });
const selectedHighlightText = ref("");
const explainedText = ref("");
const copied = ref(false);
let copiedTimer;
// Each programmatic highlight is rendered as a `.vpv-highlight__text` element.
const HIGHLIGHT_TEXT_SELECTOR =
".vpv-custom-highlight-layer-wrapper .vpv-highlight__text";
const highlightKeywords = async () => {
await highlightControl.value?.highlight([
{
keyword:
"Trace-based Just-in-Time Type Specialization for Dynamic",
highlightColor: "rgba(74, 222, 128, 0.45)",
},
{
keyword: "Dynamic languages such as JavaScript are more difficult",
highlightColor: "rgba(0, 245, 255, 0.5)",
},
]);
// Wait until Vue and the viewer finish rendering the highlight DOM nodes.
await nextTick();
window.setTimeout(prepareHighlightElements, 100);
};
const prepareHighlightElements = () => {
const viewerElement = vpvRef.value?.$el;
const highlightElements = viewerElement?.querySelectorAll(
HIGHLIGHT_TEXT_SELECTOR
);
// Make the rendered highlight spans behave like accessible action targets.
highlightElements?.forEach((element) => {
element.classList.add("highlight-action-target");
element.setAttribute("role", "button");
element.setAttribute("tabindex", "0");
element.setAttribute(
"aria-label",
`Open actions for ${getHighlightText(element)}`
);
});
};
const getHighlightText = (element) => {
return element.getAttribute("title")?.trim() || "Highlighted text";
};
const openPopover = (element) => {
const bounds = element.getBoundingClientRect();
selectedHighlightText.value = getHighlightText(element);
popoverPosition.value = {
x: bounds.left + bounds.width / 2,
y: bounds.bottom + 8,
};
showPopover.value = true;
};
const getClickedHighlightElement = (event) => {
// Prefer the clicked highlight element when the browser reports it directly.
const highlightElement = event.target.closest(HIGHLIGHT_TEXT_SELECTOR);
if (highlightElement) return highlightElement;
const viewerElement = vpvRef.value?.$el;
const highlightElements = viewerElement?.querySelectorAll(
HIGHLIGHT_TEXT_SELECTOR
);
// Some PDF layers may sit above highlights
return Array.from(highlightElements ?? []).find((element) => {
const bounds = element.getBoundingClientRect();
return (
event.clientX >= bounds.left &&
event.clientX <= bounds.right &&
event.clientY >= bounds.top &&
event.clientY <= bounds.bottom
);
});
};
const handleHighlightClick = (event) => {
const highlightElement = getClickedHighlightElement(event);
if (!highlightElement) return;
event.preventDefault();
event.stopPropagation();
openPopover(highlightElement);
};
const handleHighlightKeydown = (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const highlightElement = event.target.closest(HIGHLIGHT_TEXT_SELECTOR);
if (!highlightElement) return;
event.preventDefault();
openPopover(highlightElement);
};
const handleDocumentClick = (event) => {
const target = event.target;
if (
target.closest(".highlight-popover") ||
target.closest(HIGHLIGHT_TEXT_SELECTOR)
) {
return;
}
showPopover.value = false;
};
const handleCopy = async () => {
if (!selectedHighlightText.value) return;
await navigator.clipboard.writeText(selectedHighlightText.value);
showPopover.value = false;
copied.value = true;
window.clearTimeout(copiedTimer);
copiedTimer = window.setTimeout(() => {
copied.value = false;
}, 1600);
};
const handleExplain = () => {
explainedText.value = selectedHighlightText.value;
showPopover.value = false;
};
watch(
() => vpvRef.value?.$el,
(viewerElement, _, onCleanup) => {
if (!viewerElement) return;
// Use delegated listeners because highlight nodes can be re-rendered by the viewer.
viewerElement.addEventListener("click", handleHighlightClick);
viewerElement.addEventListener("keydown", handleHighlightKeydown);
document.addEventListener("click", handleDocumentClick);
// Highlight elements are rendered after the PDF page content is ready.
highlightKeywords();
onCleanup(() => {
viewerElement.removeEventListener("click", handleHighlightClick);
viewerElement.removeEventListener("keydown", handleHighlightKeydown);
document.removeEventListener("click", handleDocumentClick);
});
},
{ flush: "post" }
);
onBeforeUnmount(() => {
window.clearTimeout(copiedTimer);
});
return {
PDF_FILE,
vpvRef,
showPopover,
popoverPosition,
selectedHighlightText,
explainedText,
copied,
handleCopy,
handleExplain,
};
},
};
</script>
<template>
<div class="highlight-example">
<div class="viewer-panel">
<VPdfViewer ref="vpvRef" :src="PDF_FILE" />
</div>
<aside class="explain-panel" aria-label="Explain highlighted text">
<form class="explain-form" @submit.prevent>
<textarea
v-model="explainedText"
class="explain-textarea"
aria-label="Highlighted text to explain"
placeholder="Click a highlighted phrase, then choose Explain."
></textarea>
<button type="submit">Submit</button>
</form>
</aside>
<div
v-if="showPopover"
class="highlight-popover"
:style="{
top: `${popoverPosition.y}px`,
left: `${popoverPosition.x}px`,
}"
>
<button type="button" @click="handleCopy">Copy</button>
<button type="button" @click="handleExplain">Explain</button>
</div>
<div v-if="copied" class="copied-banner">Text copied</div>
</div>
</template>
<style scoped>
/* Same styles as Composition TS example */
.highlight-example {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
padding: 8px;
min-height: 700px;
}
.viewer-panel {
min-width: 0;
height: 700px;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.explain-panel {
display: flex;
align-items: flex-end;
min-height: 700px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
color: #111827;
overflow: hidden;
}
.explain-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
width: 100%;
border-top: 1px solid #d1d5db;
}
.explain-textarea {
min-height: 72px;
padding: 8px;
font: inherit;
line-height: 1.5;
color: #111827;
resize: vertical;
border: 0;
border-right: 1px solid #d1d5db;
outline: none;
}
.explain-textarea:focus {
box-shadow: inset 0 0 0 2px #2563eb;
}
.explain-form button {
font: inherit;
font-weight: 600;
color: #111827;
cursor: pointer;
background: #ffffff;
border: 0;
}
.explain-form button:hover,
.explain-form button:focus-visible {
background: #f3f4f6;
outline: none;
box-shadow: inset 0 0 0 2px #2563eb;
}
.highlight-popover {
position: fixed;
z-index: 1000;
display: flex;
gap: 4px;
padding: 4px;
background: #111827;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
transform: translateX(-50%);
}
.highlight-popover button {
padding: 8px 12px;
color: #ffffff;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 6px;
}
.highlight-popover button:hover,
.highlight-popover button:focus-visible {
background: rgba(255, 255, 255, 0.14);
}
.copied-banner {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 1000;
padding: 10px 14px;
color: #ffffff;
background: #111827;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.24);
}
:deep(.vpv-custom-highlight-layer-wrapper) {
pointer-events: none;
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text),
:deep(.highlight-action-target) {
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 2px;
pointer-events: auto;
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text:hover),
:deep(.highlight-action-target:hover) {
box-shadow: 0 0 0 2px rgba(17, 24, 39, 0.3);
}
:deep(.vpv-custom-highlight-layer-wrapper .vpv-highlight__text:focus-visible),
:deep(.highlight-action-target:focus-visible) {
outline-color: #2563eb;
}
@media (max-width: 900px) {
.highlight-example {
grid-template-columns: 1fr;
}
.viewer-panel,
.explain-panel {
min-height: auto;
height: 620px;
}
}
</style>