| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 | /* Copyright 2021 Mozilla Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * *     http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *//** @typedef {import("./event_utils").EventBus} EventBus */// eslint-disable-next-line max-len/** @typedef {import("./pdf_find_controller").PDFFindController} PDFFindController *//** * @typedef {Object} TextHighlighterOptions * @property {PDFFindController} findController * @property {EventBus} eventBus - The application event bus. * @property {number} pageIndex - The page index. *//** * TextHighlighter handles highlighting matches from the FindController in * either the text layer or XFA layer depending on the type of document. */class TextHighlighter {  /**   * @param {TextHighlighterOptions} options   */  constructor({ findController, eventBus, pageIndex }) {    this.findController = findController;    this.matches = [];    this.eventBus = eventBus;    this.pageIdx = pageIndex;    this._onUpdateTextLayerMatches = null;    this.textDivs = null;    this.textContentItemsStr = null;    this.enabled = false;  }  /**   * Store two arrays that will map DOM nodes to text they should contain.   * The arrays should be of equal length and the array element at each index   * should correspond to the other. e.g.   * `items[0] = "<span>Item 0</span>" and texts[0] = "Item 0";   *   * @param {Array<Node>} divs   * @param {Array<string>} texts   */  setTextMapping(divs, texts) {    this.textDivs = divs;    this.textContentItemsStr = texts;  }  /**   * Start listening for events to update the highlighter and check if there are   * any current matches that need be highlighted.   */  enable() {    if (!this.textDivs || !this.textContentItemsStr) {      throw new Error("Text divs and strings have not been set.");    }    if (this.enabled) {      throw new Error("TextHighlighter is already enabled.");    }    this.enabled = true;    if (!this._onUpdateTextLayerMatches) {      this._onUpdateTextLayerMatches = evt => {        if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) {          this._updateMatches();        }      };      this.eventBus._on(        "updatetextlayermatches",        this._onUpdateTextLayerMatches      );    }    this._updateMatches();  }  disable() {    if (!this.enabled) {      return;    }    this.enabled = false;    if (this._onUpdateTextLayerMatches) {      this.eventBus._off(        "updatetextlayermatches",        this._onUpdateTextLayerMatches      );      this._onUpdateTextLayerMatches = null;    }    this._updateMatches(/* reset = */ true);  }  _convertMatches(matches, matchesLength) {    // Early exit if there is nothing to convert.    if (!matches) {      return [];    }    const { textContentItemsStr } = this;    let i = 0,      iIndex = 0;    const end = textContentItemsStr.length - 1;    const result = [];    for (let m = 0, mm = matches.length; m < mm; m++) {      // Calculate the start position.      let matchIdx = matches[m];      // Loop over the divIdxs.      while (i !== end && matchIdx >= iIndex + textContentItemsStr[i].length) {        iIndex += textContentItemsStr[i].length;        i++;      }      if (i === textContentItemsStr.length) {        console.error("Could not find a matching mapping");      }      const match = {        begin: {          divIdx: i,          offset: matchIdx - iIndex,        },      };      // Calculate the end position.      matchIdx += matchesLength[m];      // Somewhat the same array as above, but use > instead of >= to get      // the end position right.      while (i !== end && matchIdx > iIndex + textContentItemsStr[i].length) {        iIndex += textContentItemsStr[i].length;        i++;      }      match.end = {        divIdx: i,        offset: matchIdx - iIndex,      };      result.push(match);    }    return result;  }  _renderMatches(matches) {    // Early exit if there is nothing to render.    if (matches.length === 0) {      return;    }    const { findController, pageIdx } = this;    const { textContentItemsStr, textDivs } = this;    const isSelectedPage = pageIdx === findController.selected.pageIdx;    const selectedMatchIdx = findController.selected.matchIdx;    const highlightAll = findController.state.highlightAll;    let prevEnd = null;    const infinity = {      divIdx: -1,      offset: undefined,    };    function beginText(begin, className) {      const divIdx = begin.divIdx;      textDivs[divIdx].textContent = "";      return appendTextToDiv(divIdx, 0, begin.offset, className);    }    function appendTextToDiv(divIdx, fromOffset, toOffset, className) {      let div = textDivs[divIdx];      if (div.nodeType === Node.TEXT_NODE) {        const span = document.createElement("span");        div.before(span);        span.append(div);        textDivs[divIdx] = span;        div = span;      }      const content = textContentItemsStr[divIdx].substring(        fromOffset,        toOffset      );      const node = document.createTextNode(content);      if (className) {        const span = document.createElement("span");        span.className = `${className} appended`;        span.append(node);        div.append(span);        return className.includes("selected") ? span.offsetLeft : 0;      }      div.append(node);      return 0;    }    let i0 = selectedMatchIdx,      i1 = i0 + 1;    if (highlightAll) {      i0 = 0;      i1 = matches.length;    } else if (!isSelectedPage) {      // Not highlighting all and this isn't the selected page, so do nothing.      return;    }    for (let i = i0; i < i1; i++) {      const match = matches[i];      const begin = match.begin;      const end = match.end;      const isSelected = isSelectedPage && i === selectedMatchIdx;      const highlightSuffix = isSelected ? " selected" : "";      let selectedLeft = 0;      // Match inside new div.      if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {        // If there was a previous div, then add the text at the end.        if (prevEnd !== null) {          appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);        }        // Clear the divs and set the content until the starting point.        beginText(begin);      } else {        appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset);      }      if (begin.divIdx === end.divIdx) {        selectedLeft = appendTextToDiv(          begin.divIdx,          begin.offset,          end.offset,          "highlight" + highlightSuffix        );      } else {        selectedLeft = appendTextToDiv(          begin.divIdx,          begin.offset,          infinity.offset,          "highlight begin" + highlightSuffix        );        for (let n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) {          textDivs[n0].className = "highlight middle" + highlightSuffix;        }        beginText(end, "highlight end" + highlightSuffix);      }      prevEnd = end;      if (isSelected) {        // Attempt to scroll the selected match into view.        findController.scrollMatchIntoView({          element: textDivs[begin.divIdx],          selectedLeft,          pageIndex: pageIdx,          matchIndex: selectedMatchIdx,        });      }    }    if (prevEnd) {      appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);    }  }  _updateMatches(reset = false) {    if (!this.enabled && !reset) {      return;    }    const { findController, matches, pageIdx } = this;    const { textContentItemsStr, textDivs } = this;    let clearedUntilDivIdx = -1;    // Clear all current matches.    for (const match of matches) {      const begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);      for (let n = begin, end = match.end.divIdx; n <= end; n++) {        const div = textDivs[n];        div.textContent = textContentItemsStr[n];        div.className = "";      }      clearedUntilDivIdx = match.end.divIdx + 1;    }    if (!findController?.highlightMatches || reset) {      return;    }    // Convert the matches on the `findController` into the match format    // used for the textLayer.    const pageMatches = findController.pageMatches[pageIdx] || null;    const pageMatchesLength = findController.pageMatchesLength[pageIdx] || null;    this.matches = this._convertMatches(pageMatches, pageMatchesLength);    this._renderMatches(this.matches);  }}export { TextHighlighter };
 |