text: /* Copyright 2012 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.
   */
  
  import { getGlobalEventBus } from './dom_events';
  import { renderTextLayer } from './pdfjs';
  
  var EXPAND_DIVS_TIMEOUT = 300; // ms
  
  /**
   * @typedef {Object} TextLayerBuilderOptions
   * @property {HTMLDivElement} textLayerDiv - The text layer container.
   * @property {EventBus} eventBus - The application event bus.
   * @property {number} pageIndex - The page index.
   * @property {PageViewport} viewport - The viewport of the text layer.
   * @property {PDFFindController} findController
   * @property {boolean} enhanceTextSelection - Option to turn on improved
   *   text selection.
   */
  
  /**
   * TextLayerBuilder provides text-selection functionality for the PDF.
   * It does this by creating overlay divs over the PDF text. These divs
   * contain text that matches the PDF text they are overlaying. This object
   * also provides a way to highlight text that is being searched for.
   * @class
   */
  var TextLayerBuilder = (function TextLayerBuilderClosure() {
    function TextLayerBuilder(options) {
      this.textLayerDiv = options.textLayerDiv;
      this.eventBus = options.eventBus || getGlobalEventBus();
      this.textContent = null;
      this.renderingDone = false;
      this.pageIdx = options.pageIndex;
      this.pageNumber = this.pageIdx + 1;
      this.matches = [];
      this.viewport = options.viewport;
      this.textDivs = [];
      this.findController = options.findController || null;
      this.textLayerRenderTask = null;
      this.enhanceTextSelection = options.enhanceTextSelection;
      this._bindMouse();
    }
  
    TextLayerBuilder.prototype = {
      /**
       * @private
       */
      _finishRendering: function TextLayerBuilder_finishRendering() {
        this.renderingDone = true;
  
        if (!this.enhanceTextSelection) {
          var endOfContent = document.createElement('div');
          endOfContent.className = 'endOfContent';
          this.textLayerDiv.appendChild(endOfContent);
        }
  
        this.eventBus.dispatch('textlayerrendered', {
          source: this,
          pageNumber: this.pageNumber,
          numTextDivs: this.textDivs.length,
        });
      },
  
      /**
       * Renders the text layer.
       * @param {number} timeout (optional) if specified, the rendering waits
       *   for specified amount of ms.
       */
      render: function TextLayerBuilder_render(timeout) {
        if (!this.textContent || this.renderingDone) {
          return;
        }
        this.cancel();
  
        this.textDivs = [];
        var textLayerFrag = document.createDocumentFragment();
        this.textLayerRenderTask = renderTextLayer({
          textContent: this.textContent,
          container: textLayerFrag,
          viewport: this.viewport,
          textDivs: this.textDivs,
          timeout: timeout,
          enhanceTextSelection: this.enhanceTextSelection,
        });
        this.textLayerRenderTask.promise.then(function () {
          this.textLayerDiv.appendChild(textLayerFrag);
          this._finishRendering();
          this.updateMatches();
        }.bind(this), function (reason) {
          // cancelled or failed to render text layer -- skipping errors
        });
      },
  
      /**
       * Cancels rendering of the text layer.
       */
      cancel: function TextLayerBuilder_cancel() {
        if (this.textLayerRenderTask) {
          this.textLayerRenderTask.cancel();
          this.textLayerRenderTask = null;
        }
      },
  
      setTextContent: function TextLayerBuilder_setTextContent(textContent) {
        this.cancel();
        this.textContent = textContent;
      },
  
      convertMatches: function TextLayerBuilder_convertMatches(matches,
                                                               matchesLength) {
        var i = 0;
        var iIndex = 0;
        var bidiTexts = this.textContent.items;
        var end = bidiTexts.length - 1;
        var queryLen = (this.findController === null ?
                        0 : this.findController.state.query.length);
        var ret = [];
        if (!matches) {
          return ret;
        }
        for (var m = 0, len = matches.length; m < len; m++) {
          // Calculate the start position.
          var matchIdx = matches[m];
  
          // Loop over the divIdxs.
          while (i !== end && matchIdx >= (iIndex + bidiTexts[i].str.length)) {
            iIndex += bidiTexts[i].str.length;
            i++;
          }
  
          if (i === bidiTexts.length) {
            console.error('Could not find a matching mapping');
          }
  
          var match = {
            begin: {
              divIdx: i,
              offset: matchIdx - iIndex
            }
          };
  
          // Calculate the end position.
          if (matchesLength) { // multiterm search
            matchIdx += matchesLength[m];
          } else { // phrase search
            matchIdx += queryLen;
          }
  
          // Somewhat the same array as above, but use > instead of >= to get
          // the end position right.
          while (i !== end && matchIdx > (iIndex + bidiTexts[i].str.length)) {
            iIndex += bidiTexts[i].str.length;
            i++;
          }
  
          match.end = {
            divIdx: i,
            offset: matchIdx - iIndex
          };
          ret.push(match);
        }
  
        return ret;
      },
  
      renderMatches: function TextLayerBuilder_renderMatches(matches) {
        // Early exit if there is nothing to render.
        if (matches.length === 0) {
          return;
        }
  
        var bidiTexts = this.textContent.items;
        var textDivs = this.textDivs;
        var prevEnd = null;
        var pageIdx = this.pageIdx;
        var isSelectedPage = (this.findController === null ?
          false : (pageIdx === this.findController.selected.pageIdx));
        var selectedMatchIdx = (this.findController === null ?
                                -1 : this.findController.selected.matchIdx);
        var highlightAll = (this.findController === null ?
                            false : this.findController.state.highlightAll);
        var infinity = {
          divIdx: -1,
          offset: undefined
        };
  
        function beginText(begin, className) {
          var divIdx = begin.divIdx;
          textDivs[divIdx].textContent = '';
          appendTextToDiv(divIdx, 0, begin.offset, className);
        }
  
        function appendTextToDiv(divIdx, fromOffset, toOffset, className) {
          var div = textDivs[divIdx];
          var content = bidiTexts[divIdx].str.substring(fromOffset, toOffset);
          var node = document.createTextNode(content);
          if (className) {
            var span = document.createElement('span');
            span.className = className;
            span.appendChild(node);
            div.appendChild(span);
            return;
          }
          div.appendChild(node);
        }
  
        var 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 (var i = i0; i < i1; i++) {
          var match = matches[i];
          var begin = match.begin;
          var end = match.end;
          var isSelected = (isSelectedPage && i === selectedMatchIdx);
          var highlightSuffix = (isSelected ? ' selected' : '');
  
          if (this.findController) {
            this.findController.updateMatchPosition(pageIdx, i, textDivs,
                                                    begin.divIdx);
          }
  
          // 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) {
            appendTextToDiv(begin.divIdx, begin.offset, end.offset,
                            'highlight' + highlightSuffix);
          } else {
            appendTextToDiv(begin.divIdx, begin.offset, infinity.offset,
                            'highlight begin' + highlightSuffix);
            for (var n0 = begin.divIdx + 1, n1 = end.divIdx; n0 < n1; n0++) {
              textDivs[n0].className = 'highlight middle' + highlightSuffix;
            }
            beginText(end, 'highlight end' + highlightSuffix);
          }
          prevEnd = end;
        }
  
        if (prevEnd) {
          appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
        }
      },
  
      updateMatches: function TextLayerBuilder_updateMatches() {
        // Only show matches when all rendering is done.
        if (!this.renderingDone) {
          return;
        }
  
        // Clear all matches.
        var matches = this.matches;
        var textDivs = this.textDivs;
        var bidiTexts = this.textContent.items;
        var clearedUntilDivIdx = -1;
  
        // Clear all current matches.
        for (var i = 0, len = matches.length; i < len; i++) {
          var match = matches[i];
          var begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);
          for (var n = begin, end = match.end.divIdx; n <= end; n++) {
            var div = textDivs[n];
            div.textContent = bidiTexts[n].str;
            div.className = '';
          }
          clearedUntilDivIdx = match.end.divIdx + 1;
        }
  
        if (this.findController === null || !this.findController.active) {
          return;
        }
  
        // Convert the matches on the page controller into the match format
        // used for the textLayer.
        var pageMatches, pageMatchesLength;
        if (this.findController !== null) {
          pageMatches = this.findController.pageMatches[this.pageIdx] || null;
          pageMatchesLength = (this.findController.pageMatchesLength) ?
            this.findController.pageMatchesLength[this.pageIdx] || null : null;
        }
  
        this.matches = this.convertMatches(pageMatches, pageMatchesLength);
        this.renderMatches(this.matches);
      },
  
      /**
       * Fixes text selection: adds additional div where mouse was clicked.
       * This reduces flickering of the content if mouse slowly dragged down/up.
       * @private
       */
      _bindMouse: function TextLayerBuilder_bindMouse() {
        var div = this.textLayerDiv;
        var self = this;
        var expandDivsTimer = null;
  
        div.addEventListener('mousedown', function (e) {
          if (self.enhanceTextSelection && self.textLayerRenderTask) {
            self.textLayerRenderTask.expandTextDivs(true);
            if ((typeof PDFJSDev === 'undefined' ||
                 !PDFJSDev.test('FIREFOX || MOZCENTRAL')) &&
                expandDivsTimer) {
              clearTimeout(expandDivsTimer);
              expandDivsTimer = null;
            }
            return;
          }
          var end = div.querySelector('.endOfContent');
          if (!end) {
            return;
          }
          if (typeof PDFJSDev === 'undefined' ||
              !PDFJSDev.test('FIREFOX || MOZCENTRAL')) {
          // On non-Firefox browsers, the selection will feel better if the height
          // of the endOfContent div will be adjusted to start at mouse click
          // location -- this will avoid flickering when selections moves up.
          // However it does not work when selection started on empty space.
          var adjustTop = e.target !== div;
          if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('GENERIC')) {
            adjustTop = adjustTop && window.getComputedStyle(end).
              getPropertyValue('-moz-user-select') !== 'none';
          }
          if (adjustTop) {
            var divBounds = div.getBoundingClientRect();
            var r = Math.max(0, (e.pageY - divBounds.top) / divBounds.height);
            end.style.top = (r * 100).toFixed(2) + '%';
          }
          }
          end.classList.add('active');
        });
  
        div.addEventListener('mouseup', function (e) {
          if (self.enhanceTextSelection && self.textLayerRenderTask) {
            if (typeof PDFJSDev === 'undefined' ||
                !PDFJSDev.test('FIREFOX || MOZCENTRAL')) {
              expandDivsTimer = setTimeout(function() {
                if (self.textLayerRenderTask) {
                  self.textLayerRenderTask.expandTextDivs(false);
                }
                expandDivsTimer = null;
              }, EXPAND_DIVS_TIMEOUT);
            } else {
              self.textLayerRenderTask.expandTextDivs(false);
            }
            return;
          }
          var end = div.querySelector('.endOfContent');
          if (!end) {
            return;
          }
          if (typeof PDFJSDev === 'undefined' ||
              !PDFJSDev.test('FIREFOX || MOZCENTRAL')) {
            end.style.top = '';
          }
          end.classList.remove('active');
        });
      },
    };
    return TextLayerBuilder;
  })();
  
  /**
   * @constructor
   * @implements IPDFTextLayerFactory
   */
  function DefaultTextLayerFactory() {}
  DefaultTextLayerFactory.prototype = {
    /**
     * @param {HTMLDivElement} textLayerDiv
     * @param {number} pageIndex
     * @param {PageViewport} viewport
     * @param {boolean} enhanceTextSelection
     * @returns {TextLayerBuilder}
     */
    createTextLayerBuilder: function (textLayerDiv, pageIndex, viewport,
                                      enhanceTextSelection) {
      return new TextLayerBuilder({
        textLayerDiv: textLayerDiv,
        pageIndex: pageIndex,
        viewport: viewport,
        enhanceTextSelection: enhanceTextSelection
      });
    }
  };
  
  export {
    TextLayerBuilder,
    DefaultTextLayerFactory,
  };
  