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 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(", ");
    },
    // One row per (page, keyword): multiple indexOnPage hits for the
    // same keyword on a page are collapsed.
    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>