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>