Skip to content

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 methodObjective
highlightApply 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 methodObjective
getTextContentRead text content for each page so matches can be counted and grouped.
goToPageMove 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 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(', ')
})
const matchesByPage = computed(() => {
  const pageMap = matches.value.reduce<Map<number, KeywordMatch[]>>((summary, match) => {
    const pageMatches = summary.get(match.page) ?? []

    pageMatches.push(match)
    summary.set(match.page, pageMatches)

    return summary
  }, new Map())

  return Array.from(pageMap, ([page, pageMatches]) => ({
    page,
    matches: pageMatches,
  }))
})

// Escape a plain string before building a dynamic RegExp.
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) {
    // Ensure RegExp is global so we can iterate through every hit.
    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

  // Collect all matches for a single highlight definition on one page.
  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
  }

  // Read every page text and aggregate matches so the sidebar can group by page.
  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 () => {
  // Prevent duplicate initialization when watcher and @loaded fire close together.
  if (!isDocumentReady.value || hasAppliedInitialHighlights.value) return

  hasAppliedInitialHighlights.value = true
  highlightControl.value?.highlight(INITIAL_HIGHLIGHTS)
  matches.value = await collectHighlightMatches(INITIAL_HIGHLIGHTS)
  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="pageGroup in matchesByPage" :key="pageGroup.page" class="match-page-group">
          {{ pageGroup.matches[0].searchKeyword }} :
          <button type="button" @click="pageControl?.goToPage(pageGroup.page)">
            Go to page {{ pageGroup.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: 660px;
  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 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(', ')
})
const matchesByPage = computed(() => {
  const pageMap = matches.value.reduce((summary, match) => {
    const pageMatches = summary.get(match.page) ?? []
    pageMatches.push(match)
    summary.set(match.page, pageMatches)
    return summary
  }, new Map())

  return Array.from(pageMap, ([page, pageMatches]) => ({ page, matches: pageMatches }))
})

// Escape a plain string before building a dynamic RegExp.
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const getKeywordLabel = ({ keyword, label }) => label ?? (typeof keyword === 'string' ? keyword : keyword.source)

const createKeywordExpression = ({ keyword, options }) => {
  if (keyword instanceof RegExp) {
    // Ensure RegExp is global so we can iterate through every hit.
    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

  // Collect all matches for a single highlight definition on one page.
  while ((match = expression.exec(pageText)) !== null) {
    pageMatches.push({
      page,
      indexOnPage: pageMatches.length,
      searchKeyword: getKeywordLabel(highlightConfig),
    })
  }

  return pageMatches
}

const collectHighlightMatches = async (highlightConfigs) => {
  const control = pageControl.value
  const totalPages = control?.totalPages ?? 0
  const collectedMatches = []

  if (!control || totalPages === 0) return collectedMatches

  // Read every page text and aggregate matches so the sidebar can group by page.
  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 highlight = async () => {
  // Prevent duplicate initialization when watcher and @loaded fire close together.
  if (!isDocumentReady.value || hasAppliedInitialHighlights.value) return

  hasAppliedInitialHighlights.value = true
  highlightControl.value?.highlight(INITIAL_HIGHLIGHTS)
  matches.value = await collectHighlightMatches(INITIAL_HIGHLIGHTS)
  statusMessage.value = `Found ${matches.value.length} highlighted matches. ${matchSummary.value}`
}

const handleDocumentLoaded = () => {
  isPdfLoaded.value = true
  statusMessage.value = 'PDF loaded. Enter a keyword and highlight it.'
  highlight()
}

watch(highlightControl, (control) => {
  if (!control) return
  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="pageGroup in matchesByPage" :key="pageGroup.page" class="match-page-group">
          {{ pageGroup.matches[0].searchKeyword }} :
          <button type="button" @click="pageControl?.goToPage(pageGroup.page)">
            Go to page {{ pageGroup.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 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[],
      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(', ')
    },
    matchesByPage(): { page: number; matches: KeywordMatch[] }[] {
      const pageMap = this.matches.reduce<Map<number, KeywordMatch[]>>((summary, match) => {
        const pageMatches = summary.get(match.page) ?? []
        pageMatches.push(match)
        summary.set(match.page, pageMatches)
        return summary
      }, new Map())
      return Array.from(pageMap, ([page, pageMatches]) => ({ page, matches: pageMatches }))
    },
  },
  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) {
        // Ensure RegExp is global so we can iterate through every hit.
        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

      // Collect all matches for a single highlight definition on one page.
      while ((match = expression.exec(pageText)) !== null) {
        pageMatches.push({
          page,
          indexOnPage: pageMatches.length,
          searchKeyword: this.getKeywordLabel(highlightConfig),
        })
      }

      return pageMatches
    },
    async collectHighlightMatches(highlightConfigs: HighlightConfig[]) {
      const control = this.pageControl
      const totalPages = control?.totalPages ?? 0
      const collectedMatches: KeywordMatch[] = []
      if (!control || totalPages === 0) return collectedMatches

      // Read every page text and aggregate matches so the sidebar can group by page.
      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() {
      // Prevent duplicate initialization when watcher and @loaded fire close together.
      if (!this.isDocumentReady || this.hasAppliedInitialHighlights) return

      this.hasAppliedInitialHighlights = true
      this.highlightControl?.highlight(this.INITIAL_HIGHLIGHTS)
      this.matches = await this.collectHighlightMatches(this.INITIAL_HIGHLIGHTS)
      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
      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="pageGroup in matchesByPage" :key="pageGroup.page" class="match-page-group">
          {{ pageGroup.matches[0].searchKeyword }} :
          <button type="button" @click="pageControl?.goToPage(pageGroup.page)">
            Go to page {{ pageGroup.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: [],
      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(', ')
    },
    matchesByPage() {
      const pageMap = this.matches.reduce((summary, match) => {
        const pageMatches = summary.get(match.page) ?? []
        pageMatches.push(match)
        summary.set(match.page, pageMatches)
        return summary
      }, new Map())
      return Array.from(pageMap, ([page, pageMatches]) => ({ page, matches: pageMatches }))
    },
  },
  methods: {
    // Escape a plain string before building a dynamic RegExp.
    escapeRegExp(value) {
      return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
    },
    getKeywordLabel(config) {
      return config.label ?? (typeof config.keyword === 'string' ? config.keyword : config.keyword.source)
    },
    createKeywordExpression(config) {
      if (config.keyword instanceof RegExp) {
        // Ensure RegExp is global so we can iterate through every hit.
        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

      // Collect all matches for a single highlight definition on one page.
      while ((match = expression.exec(pageText)) !== null) {
        pageMatches.push({
          page,
          indexOnPage: pageMatches.length,
          searchKeyword: this.getKeywordLabel(highlightConfig),
        })
      }

      return pageMatches
    },
    async collectHighlightMatches(highlightConfigs) {
      const control = this.pageControl
      const totalPages = control?.totalPages ?? 0
      const collectedMatches = []
      if (!control || totalPages === 0) return collectedMatches

      // Read every page text and aggregate matches so the sidebar can group by page.
      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() {
      // Prevent duplicate initialization when watcher and @loaded fire close together.
      if (!this.isDocumentReady || this.hasAppliedInitialHighlights) return

      this.hasAppliedInitialHighlights = true
      this.highlightControl?.highlight(this.INITIAL_HIGHLIGHTS)
      this.matches = await this.collectHighlightMatches(this.INITIAL_HIGHLIGHTS)
      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
      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="pageGroup in matchesByPage" :key="pageGroup.page" class="match-page-group">
          {{ pageGroup.matches[0].searchKeyword }} :
          <button type="button" @click="pageControl?.goToPage(pageGroup.page)">
            Go to page {{ pageGroup.page }}
          </button>
        </div>
      </div>

      <div class="viewer-frame">
        <VPdfViewer ref="vpvRef" :src="SAMPLE_PDF_SOURCE" style="height: 600px;" @loaded="handleDocumentLoaded" />
      </div>
    </div>
  </section>
</template>