Appearance
Jump to the Next Highlighted Keyword Programmatically
Scenario
When the PDF document is loaded, Vue PDF Viewer programmatically highlights three search terms:
- Phrase match:
"compilation technique" - Case-sensitive whole word:
"JavaScript" - Whole word with regular expression:
"language"or"languages"
The selected terms should appear more than once and be located across different pages. Each highlighted term uses a different color.
Vue PDF Viewer should allow users to view and navigate to where the keywords are found in the PDF document.
What to Use
The highlightControl instance provides methods to highlight text in the Vue PDF component without manual text selection.
Here are the instance methods used in this example:
| Instance method | Objective |
|---|---|
highlight | Apply initial keyword and RegExp highlights after the document is ready. |
The pageControl instance provides page state and navigation methods used to collect and browse highlighted matches:
| Instance method | Objective |
|---|---|
getTextContent | Read text content for each page so matches can be counted and grouped. |
goToPage | Move focus to the page that contains the selected highlighted match. |
Code example
vue
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
type TextItem = {
str?: string;
};
type TextContentLike = {
items?: TextItem[];
};
type KeywordMatch = {
page: number;
indexOnPage: number;
searchKeyword: string;
};
type HighlightConfig = {
keyword: string | RegExp;
label?: string;
highlightColor?: string;
options?: {
matchCase?: boolean;
wholeWords?: boolean;
};
};
type ViewerPageControl = {
currentPage: number;
totalPages: number;
goToPage: (page: number) => void;
getTextContent: (page: number) => Promise<TextContentLike>;
};
type ViewerHighlightControl = {
clear: () => void;
highlight: (highlights: HighlightConfig[]) => void;
};
// Replace this URL with a PDF asset from your docs project before publishing.
const SAMPLE_PDF_SOURCE =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const INITIAL_HIGHLIGHTS: HighlightConfig[] = [
{
keyword: "compilation technique",
highlightColor: "rgba(255, 179, 0, 0.5)",
},
{
keyword: "JavaScript",
highlightColor: "rgba(0, 255, 0, 0.5)",
options: { matchCase: true, wholeWords: true },
},
{
keyword: /\blanguage(s)?\b/,
label: "language",
highlightColor: "rgba(255, 0, 255, 0.5)",
}, // RegExp: match "language" or "languages"
];
const vpvRef = ref<InstanceType<typeof VPdfViewer>>();
const matches = ref<KeywordMatch[]>([]);
const currentMatchIndex = ref(-1);
const isPdfLoaded = ref(false);
const hasAppliedInitialHighlights = ref(false);
const statusMessage = ref("Load a PDF, then highlight a keyword.");
const highlightControl = computed(
() => vpvRef.value?.highlightControl as ViewerHighlightControl | undefined,
);
const pageControl = computed(
() => vpvRef.value?.pageControl as ViewerPageControl | undefined,
);
const isDocumentReady = computed(
() =>
isPdfLoaded.value &&
Boolean(highlightControl.value) &&
Boolean(pageControl.value),
);
const hasMatches = computed(() => matches.value.length > 0);
const matchSummary = computed(() => {
if (matches.value.length === 0) return "No highlighted matches yet.";
const counts = matches.value.reduce<Record<string, number>>(
(summary, match) => {
summary[match.searchKeyword] = (summary[match.searchKeyword] ?? 0) + 1;
return summary;
},
{},
);
return Object.entries(counts)
.map(([word, count]) => `${word}: ${count}`)
.join(", ");
});
/** One row per (page, keyword): multiple indexOnPage hits for the same keyword on a page are collapsed. */
const uniqueKeywordsByPage = computed(() => {
const seen = new Set<string>();
const rows: { page: number; searchKeyword: string }[] = [];
for (const match of matches.value) {
const key = `${match.page}:${match.searchKeyword}`;
if (seen.has(key)) continue;
seen.add(key);
rows.push({ page: match.page, searchKeyword: match.searchKeyword });
}
rows.sort(
(a, b) => a.page - b.page || a.searchKeyword.localeCompare(b.searchKeyword),
);
return rows;
});
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const getKeywordLabel = ({ keyword, label }: HighlightConfig) => {
if (label) return label;
return typeof keyword === "string" ? keyword : keyword.source;
};
const createKeywordExpression = ({ keyword, options }: HighlightConfig) => {
if (keyword instanceof RegExp) {
const flags = keyword.flags.includes("g")
? keyword.flags
: `${keyword.flags}g`;
return new RegExp(keyword.source, flags);
}
const source = options?.wholeWords
? `\\b${escapeRegExp(keyword)}\\b`
: escapeRegExp(keyword);
const flags = options?.matchCase ? "g" : "gi";
return new RegExp(source, flags);
};
const normalizeTextContent = (textContent: TextContentLike) =>
(textContent.items ?? []).map((item) => item.str ?? "").join(" ");
const findMatchesOnPage = (
pageText: string,
highlightConfig: HighlightConfig,
page: number,
) => {
const expression = createKeywordExpression(highlightConfig);
const pageMatches: KeywordMatch[] = [];
let match: RegExpExecArray | null;
while ((match = expression.exec(pageText)) !== null) {
const searchKeyword = getKeywordLabel(highlightConfig);
pageMatches.push({
page,
indexOnPage: pageMatches.length,
searchKeyword,
});
}
return pageMatches;
};
const collectHighlightMatches = async (highlightConfigs: HighlightConfig[]) => {
const control = pageControl.value;
const totalPages = control?.totalPages ?? 0;
const collectedMatches: KeywordMatch[] = [];
if (!control || totalPages === 0) {
return collectedMatches;
}
for (let page = 1; page <= totalPages; page += 1) {
const textContent = await control.getTextContent(page);
const pageText = normalizeTextContent(textContent);
highlightConfigs.forEach((highlightConfig) => {
collectedMatches.push(
...findMatchesOnPage(pageText, highlightConfig, page),
);
});
}
return collectedMatches;
};
const handleDocumentLoaded = () => {
isPdfLoaded.value = true;
statusMessage.value = "PDF loaded. Enter a keyword and highlight it.";
highlight();
};
const highlight = async () => {
if (!isDocumentReady.value || hasAppliedInitialHighlights.value) return;
hasAppliedInitialHighlights.value = true;
highlightControl.value?.highlight(INITIAL_HIGHLIGHTS);
matches.value = await collectHighlightMatches(INITIAL_HIGHLIGHTS);
currentMatchIndex.value = -1;
statusMessage.value = `Found ${matches.value.length} highlighted matches. ${matchSummary.value}`;
};
watch(highlightControl, (control) => {
if (!control) return;
// Initiate the highlight
highlight();
});
</script>
<template>
<section
class="highlight-keyword-example"
aria-labelledby="highlight-keyword-title"
>
<header class="example-header">
<h2 id="highlight-keyword-title">
Jump to the Next Highlighted Keyword Programmatically
</h2>
</header>
<div class="example-content">
<div v-if="hasMatches" class="example-matches">
<p>{{ matchSummary }}</p>
<div
v-for="row in uniqueKeywordsByPage"
:key="`${row.page}-${row.searchKeyword}`"
class="match-page-group"
>
{{ row.searchKeyword }} :
<button type="button" @click="pageControl?.goToPage(row.page)">
Go to page {{ row.page }}
</button>
</div>
</div>
<div class="viewer-frame">
<VPdfViewer
ref="vpvRef"
:src="SAMPLE_PDF_SOURCE"
style="height: 600px;"
@loaded="handleDocumentLoaded"
/>
</div>
</div>
</section>
</template>
<style scoped>
.highlight-keyword-example {
display: grid;
gap: 1rem;
width: 100%;
}
.example-header {
display: grid;
gap: 0.25rem;
text-align: center;
}
.example-header h2,
.example-header p,
.example-status {
margin: 0;
}
.example-controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
}
.keyword-field {
display: grid;
gap: 0.25rem;
min-width: min(100%, 16rem);
font-weight: 600;
}
.keyword-field input,
.example-controls button {
min-height: 2.5rem;
border: 1px solid #c8d0d9;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.example-controls button {
cursor: pointer;
}
.example-controls button:disabled,
.keyword-field input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.example-status {
color: #46515f;
}
.example-content {
display: flex;
gap: 1rem;
}
.example-matches {
max-height: 600px;
overflow-y: auto;
}
.match-page-group {
display: flex;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.viewer-frame {
width: 100%;
min-height: 42rem;
overflow: hidden;
border-radius: 0.5rem;
}
@media (max-width: 640px) {
.example-controls {
align-items: stretch;
}
.keyword-field,
.example-controls button {
width: 100%;
}
.viewer-frame {
min-height: 32rem;
}
}
</style>vue
<script setup>
import { computed, ref, watch } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
// Replace this URL with a PDF asset from your docs project before publishing.
const SAMPLE_PDF_SOURCE =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const INITIAL_HIGHLIGHTS = [
{
keyword: "compilation technique",
highlightColor: "rgba(255, 179, 0, 0.5)",
},
{
keyword: "JavaScript",
highlightColor: "rgba(0, 255, 0, 0.5)",
options: { matchCase: true, wholeWords: true },
},
{
keyword: /\blanguage(s)?\b/,
label: "language",
highlightColor: "rgba(255, 0, 255, 0.5)",
}, // RegExp: match "language" or "languages"
];
const vpvRef = ref();
const matches = ref([]);
const currentMatchIndex = ref(-1);
const isPdfLoaded = ref(false);
const hasAppliedInitialHighlights = ref(false);
const statusMessage = ref("Load a PDF, then highlight a keyword.");
const highlightControl = computed(() => vpvRef.value?.highlightControl);
const pageControl = computed(() => vpvRef.value?.pageControl);
const isDocumentReady = computed(
() =>
isPdfLoaded.value &&
Boolean(highlightControl.value) &&
Boolean(pageControl.value),
);
const hasMatches = computed(() => matches.value.length > 0);
const matchSummary = computed(() => {
if (matches.value.length === 0) return "No highlighted matches yet.";
const counts = matches.value.reduce((summary, match) => {
summary[match.searchKeyword] = (summary[match.searchKeyword] ?? 0) + 1;
return summary;
}, {});
return Object.entries(counts)
.map(([word, count]) => `${word}: ${count}`)
.join(", ");
});
/** One row per (page, keyword): multiple indexOnPage hits for the same keyword on a page are collapsed. */
const uniqueKeywordsByPage = computed(() => {
const seen = new Set();
const rows = [];
for (const match of matches.value) {
const key = `${match.page}:${match.searchKeyword}`;
if (seen.has(key)) continue;
seen.add(key);
rows.push({ page: match.page, searchKeyword: match.searchKeyword });
}
rows.sort(
(a, b) => a.page - b.page || a.searchKeyword.localeCompare(b.searchKeyword),
);
return rows;
});
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const getKeywordLabel = ({ keyword, label }) => {
if (label) return label;
return typeof keyword === "string" ? keyword : keyword.source;
};
const createKeywordExpression = ({ keyword, options }) => {
if (keyword instanceof RegExp) {
const flags = keyword.flags.includes("g")
? keyword.flags
: `${keyword.flags}g`;
return new RegExp(keyword.source, flags);
}
const source = options?.wholeWords
? `\\b${escapeRegExp(keyword)}\\b`
: escapeRegExp(keyword);
const flags = options?.matchCase ? "g" : "gi";
return new RegExp(source, flags);
};
const normalizeTextContent = (textContent) =>
(textContent.items ?? []).map((item) => item.str ?? "").join(" ");
const findMatchesOnPage = (pageText, highlightConfig, page) => {
const expression = createKeywordExpression(highlightConfig);
const pageMatches = [];
let match;
while ((match = expression.exec(pageText)) !== null) {
const searchKeyword = getKeywordLabel(highlightConfig);
pageMatches.push({
page,
indexOnPage: pageMatches.length,
searchKeyword,
});
}
return pageMatches;
};
const collectHighlightMatches = async (highlightConfigs) => {
const control = pageControl.value;
const totalPages = control?.totalPages ?? 0;
const collectedMatches = [];
if (!control || totalPages === 0) {
return collectedMatches;
}
for (let page = 1; page <= totalPages; page += 1) {
const textContent = await control.getTextContent(page);
const pageText = normalizeTextContent(textContent);
highlightConfigs.forEach((highlightConfig) => {
collectedMatches.push(
...findMatchesOnPage(pageText, highlightConfig, page),
);
});
}
return collectedMatches;
};
const handleDocumentLoaded = () => {
isPdfLoaded.value = true;
statusMessage.value = "PDF loaded. Enter a keyword and highlight it.";
highlight();
};
const highlight = async () => {
if (!isDocumentReady.value || hasAppliedInitialHighlights.value) return;
hasAppliedInitialHighlights.value = true;
highlightControl.value?.highlight(INITIAL_HIGHLIGHTS);
matches.value = await collectHighlightMatches(INITIAL_HIGHLIGHTS);
currentMatchIndex.value = -1;
statusMessage.value = `Found ${matches.value.length} highlighted matches. ${matchSummary.value}`;
};
watch(highlightControl, (control) => {
if (!control) return;
// Initiate the highlight
highlight();
});
</script>
<template>
<section
class="highlight-keyword-example"
aria-labelledby="highlight-keyword-title"
>
<header class="example-header">
<h2 id="highlight-keyword-title">
Jump to the Next Highlighted Keyword Programmatically
</h2>
</header>
<div class="example-content">
<div v-if="hasMatches" class="example-matches">
<p>{{ matchSummary }}</p>
<div
v-for="row in uniqueKeywordsByPage"
:key="`${row.page}-${row.searchKeyword}`"
class="match-page-group"
>
{{ row.searchKeyword }} :
<button type="button" @click="pageControl?.goToPage(row.page)">
Go to page {{ row.page }}
</button>
</div>
</div>
<div class="viewer-frame">
<VPdfViewer
ref="vpvRef"
:src="SAMPLE_PDF_SOURCE"
style="height: 600px;"
@loaded="handleDocumentLoaded"
/>
</div>
</div>
</section>
</template>
<style scoped>
.highlight-keyword-example {
display: grid;
gap: 1rem;
width: 100%;
}
.example-header {
display: grid;
gap: 0.25rem;
text-align: center;
}
.example-header h2,
.example-header p,
.example-status {
margin: 0;
}
.example-controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
}
.keyword-field {
display: grid;
gap: 0.25rem;
min-width: min(100%, 16rem);
font-weight: 600;
}
.keyword-field input,
.example-controls button {
min-height: 2.5rem;
border: 1px solid #c8d0d9;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.example-controls button {
cursor: pointer;
}
.example-controls button:disabled,
.keyword-field input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.example-status {
color: #46515f;
}
.example-content {
display: flex;
gap: 1rem;
}
.example-matches {
max-height: 600px;
overflow-y: auto;
}
.match-page-group {
display: flex;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.viewer-frame {
width: 100%;
min-height: 42rem;
overflow: hidden;
border-radius: 0.5rem;
}
@media (max-width: 640px) {
.example-controls {
align-items: stretch;
}
.keyword-field,
.example-controls button {
width: 100%;
}
.viewer-frame {
min-height: 32rem;
}
}
</style>vue
<script lang="ts">
import { defineComponent } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
type TextItem = { str?: string };
type TextContentLike = { items?: TextItem[] };
type KeywordMatch = {
page: number;
indexOnPage: number;
searchKeyword: string;
};
type HighlightConfig = {
keyword: string | RegExp;
label?: string;
highlightColor?: string;
options?: { matchCase?: boolean; wholeWords?: boolean };
};
export default defineComponent({
name: "JumpToNextHighlightedKeywordProgrammaticallyOptionsTs",
components: { VPdfViewer },
data() {
return {
SAMPLE_PDF_SOURCE:
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf",
INITIAL_HIGHLIGHTS: [
{
keyword: "compilation technique",
highlightColor: "rgba(255, 179, 0, 0.5)",
},
{
keyword: "JavaScript",
highlightColor: "rgba(0, 255, 0, 0.5)",
options: { matchCase: true, wholeWords: true },
},
{
keyword: /\blanguage(s)?\b/,
label: "language",
highlightColor: "rgba(255, 0, 255, 0.5)",
},
] as HighlightConfig[],
matches: [] as KeywordMatch[],
currentMatchIndex: -1,
isPdfLoaded: false,
hasAppliedInitialHighlights: false,
statusMessage: "Load a PDF, then highlight a keyword.",
};
},
computed: {
highlightControl(): any {
return (this.$refs.vpvRef as any)?.highlightControl;
},
pageControl(): any {
return (this.$refs.vpvRef as any)?.pageControl;
},
isDocumentReady(): boolean {
return (
this.isPdfLoaded &&
Boolean(this.highlightControl) &&
Boolean(this.pageControl)
);
},
hasMatches(): boolean {
return this.matches.length > 0;
},
matchSummary(): string {
if (this.matches.length === 0) return "No highlighted matches yet.";
const counts = this.matches.reduce<Record<string, number>>(
(summary, match) => {
summary[match.searchKeyword] =
(summary[match.searchKeyword] ?? 0) + 1;
return summary;
},
{},
);
return Object.entries(counts)
.map(([word, count]) => `${word}: ${count}`)
.join(", ");
},
/** One row per (page, keyword): multiple indexOnPage hits for the same keyword on a page are collapsed. */
uniqueKeywordsByPage(): { page: number; searchKeyword: string }[] {
const seen = new Set<string>();
const rows: { page: number; searchKeyword: string }[] = [];
for (const match of this.matches) {
const key = `${match.page}:${match.searchKeyword}`;
if (seen.has(key)) continue;
seen.add(key);
rows.push({ page: match.page, searchKeyword: match.searchKeyword });
}
rows.sort(
(a, b) =>
a.page - b.page || a.searchKeyword.localeCompare(b.searchKeyword),
);
return rows;
},
},
methods: {
// Escape a plain string before building a dynamic RegExp.
escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
},
getKeywordLabel(config: HighlightConfig) {
if (config.label) return config.label;
return typeof config.keyword === "string"
? config.keyword
: config.keyword.source;
},
createKeywordExpression(config: HighlightConfig) {
if (config.keyword instanceof RegExp) {
const flags = config.keyword.flags.includes("g")
? config.keyword.flags
: `${config.keyword.flags}g`;
return new RegExp(config.keyword.source, flags);
}
const source = config.options?.wholeWords
? `\\b${this.escapeRegExp(config.keyword)}\\b`
: this.escapeRegExp(config.keyword);
const flags = config.options?.matchCase ? "g" : "gi";
return new RegExp(source, flags);
},
normalizeTextContent(textContent: TextContentLike) {
return (textContent.items ?? []).map((item) => item.str ?? "").join(" ");
},
findMatchesOnPage(
pageText: string,
highlightConfig: HighlightConfig,
page: number,
) {
const expression = this.createKeywordExpression(highlightConfig);
const pageMatches: KeywordMatch[] = [];
let match: RegExpExecArray | null;
while ((match = expression.exec(pageText)) !== null) {
const searchKeyword = this.getKeywordLabel(highlightConfig);
pageMatches.push({
page,
indexOnPage: pageMatches.length,
searchKeyword,
});
}
return pageMatches;
},
async collectHighlightMatches(highlightConfigs: HighlightConfig[]) {
const control = this.pageControl;
const totalPages = control?.totalPages ?? 0;
const collectedMatches: KeywordMatch[] = [];
if (!control || totalPages === 0) return collectedMatches;
for (let page = 1; page <= totalPages; page += 1) {
const textContent = await control.getTextContent(page);
const pageText = this.normalizeTextContent(textContent);
highlightConfigs.forEach((highlightConfig) => {
collectedMatches.push(
...this.findMatchesOnPage(pageText, highlightConfig, page),
);
});
}
return collectedMatches;
},
async highlight() {
if (!this.isDocumentReady || this.hasAppliedInitialHighlights) return;
this.hasAppliedInitialHighlights = true;
this.highlightControl?.highlight(this.INITIAL_HIGHLIGHTS);
this.matches = await this.collectHighlightMatches(
this.INITIAL_HIGHLIGHTS,
);
this.currentMatchIndex = -1;
this.statusMessage = `Found ${this.matches.length} highlighted matches. ${this.matchSummary}`;
},
handleDocumentLoaded() {
this.isPdfLoaded = true;
this.statusMessage = "PDF loaded. Enter a keyword and highlight it.";
void this.highlight();
},
},
watch: {
highlightControl(control: any) {
if (!control) return;
// Initiate the highlight
void this.highlight();
},
},
});
</script>
<template>
<section
class="highlight-keyword-example"
aria-labelledby="highlight-keyword-title"
>
<header class="example-header">
<h2 id="highlight-keyword-title">
Jump to the Next Highlighted Keyword Programmatically
</h2>
</header>
<div class="example-content">
<div v-if="hasMatches" class="example-matches">
<p>{{ matchSummary }}</p>
<div
v-for="row in uniqueKeywordsByPage"
:key="`${row.page}-${row.searchKeyword}`"
class="match-page-group"
>
{{ row.searchKeyword }} :
<button type="button" @click="pageControl?.goToPage(row.page)">
Go to page {{ row.page }}
</button>
</div>
</div>
<div class="viewer-frame">
<VPdfViewer
ref="vpvRef"
:src="SAMPLE_PDF_SOURCE"
style="height: 600px;"
@loaded="handleDocumentLoaded"
/>
</div>
</div>
</section>
</template>vue
<script>
import { defineComponent } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
export default defineComponent({
name: "JumpToNextHighlightedKeywordProgrammaticallyOptionsJs",
components: { VPdfViewer },
data() {
return {
SAMPLE_PDF_SOURCE:
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf",
INITIAL_HIGHLIGHTS: [
{
keyword: "compilation technique",
highlightColor: "rgba(255, 179, 0, 0.5)",
},
{
keyword: "JavaScript",
highlightColor: "rgba(0, 255, 0, 0.5)",
options: { matchCase: true, wholeWords: true },
},
{
keyword: /\blanguage(s)?\b/,
label: "language",
highlightColor: "rgba(255, 0, 255, 0.5)",
},
],
matches: [],
currentMatchIndex: -1,
isPdfLoaded: false,
hasAppliedInitialHighlights: false,
statusMessage: "Load a PDF, then highlight a keyword.",
};
},
computed: {
highlightControl() {
return this.$refs.vpvRef?.highlightControl;
},
pageControl() {
return this.$refs.vpvRef?.pageControl;
},
isDocumentReady() {
return (
this.isPdfLoaded &&
Boolean(this.highlightControl) &&
Boolean(this.pageControl)
);
},
hasMatches() {
return this.matches.length > 0;
},
matchSummary() {
if (this.matches.length === 0) return "No highlighted matches yet.";
const counts = this.matches.reduce((summary, match) => {
summary[match.searchKeyword] = (summary[match.searchKeyword] ?? 0) + 1;
return summary;
}, {});
return Object.entries(counts)
.map(([word, count]) => `${word}: ${count}`)
.join(", ");
},
uniqueKeywordsByPage() {
const seen = new Set();
const rows = [];
for (const match of this.matches) {
const key = `${match.page}:${match.searchKeyword}`;
if (seen.has(key)) continue;
seen.add(key);
rows.push({ page: match.page, searchKeyword: match.searchKeyword });
}
rows.sort(
(a, b) =>
a.page - b.page || a.searchKeyword.localeCompare(b.searchKeyword),
);
return rows;
},
},
methods: {
escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
},
getKeywordLabel(config) {
if (config.label) return config.label;
return typeof config.keyword === "string"
? config.keyword
: config.keyword.source;
},
createKeywordExpression(config) {
if (config.keyword instanceof RegExp) {
const flags = config.keyword.flags.includes("g")
? config.keyword.flags
: `${config.keyword.flags}g`;
return new RegExp(config.keyword.source, flags);
}
const source = config.options?.wholeWords
? `\\b${this.escapeRegExp(config.keyword)}\\b`
: this.escapeRegExp(config.keyword);
const flags = config.options?.matchCase ? "g" : "gi";
return new RegExp(source, flags);
},
normalizeTextContent(textContent) {
return (textContent.items ?? []).map((item) => item.str ?? "").join(" ");
},
findMatchesOnPage(pageText, highlightConfig, page) {
const expression = this.createKeywordExpression(highlightConfig);
const pageMatches = [];
let match;
while ((match = expression.exec(pageText)) !== null) {
const searchKeyword = this.getKeywordLabel(highlightConfig);
pageMatches.push({
page,
indexOnPage: pageMatches.length,
searchKeyword,
});
}
return pageMatches;
},
async collectHighlightMatches(highlightConfigs) {
const control = this.pageControl;
const totalPages = control?.totalPages ?? 0;
const collectedMatches = [];
if (!control || totalPages === 0) return collectedMatches;
for (let page = 1; page <= totalPages; page += 1) {
const textContent = await control.getTextContent(page);
const pageText = this.normalizeTextContent(textContent);
highlightConfigs.forEach((highlightConfig) => {
collectedMatches.push(
...this.findMatchesOnPage(pageText, highlightConfig, page),
);
});
}
return collectedMatches;
},
async highlight() {
if (!this.isDocumentReady || this.hasAppliedInitialHighlights) return;
this.hasAppliedInitialHighlights = true;
this.highlightControl?.highlight(this.INITIAL_HIGHLIGHTS);
this.matches = await this.collectHighlightMatches(
this.INITIAL_HIGHLIGHTS,
);
this.currentMatchIndex = -1;
this.statusMessage = `Found ${this.matches.length} highlighted matches. ${this.matchSummary}`;
},
handleDocumentLoaded() {
this.isPdfLoaded = true;
this.statusMessage = "PDF loaded. Enter a keyword and highlight it.";
void this.highlight();
},
},
watch: {
highlightControl(control) {
if (!control) return;
// Initiate the highlight
void this.highlight();
},
},
});
</script>
<template>
<section
class="highlight-keyword-example"
aria-labelledby="highlight-keyword-title"
>
<header class="example-header">
<h2 id="highlight-keyword-title">
Jump to the Next Highlighted Keyword Programmatically
</h2>
</header>
<div class="example-content">
<div v-if="hasMatches" class="example-matches">
<p>{{ matchSummary }}</p>
<div
v-for="row in uniqueKeywordsByPage"
:key="`${row.page}-${row.searchKeyword}`"
class="match-page-group"
>
{{ row.searchKeyword }} :
<button type="button" @click="pageControl?.goToPage(row.page)">
Go to page {{ row.page }}
</button>
</div>
</div>
<div class="viewer-frame">
<VPdfViewer
ref="vpvRef"
:src="SAMPLE_PDF_SOURCE"
style="height: 600px;"
@loaded="handleDocumentLoaded"
/>
</div>
</div>
</section>
</template>