nus-mtp/sashimi-note

View on GitHub
sashimi-webapp/src/logic/renderer/core.js

Summary

Maintainability
C
7 hrs
Test Coverage
import VirtualBook from './VirtualBook';
import helper from './helper';

const CLASS_NAME_PREFIX = 'page-view';

const constructChildHeightsArray = function constructChildHeightsArray(childNodes) {
  const childArray = Object.keys(childNodes).map(key => childNodes[key]);
  const childHeights =
    childArray.filter(childNode => (childNode.nodeName !== '#text'))
              .map((childNode) => {
                const totalHeight = helper.computeElementHeight(childNode);

                // Add debug infomation into the dom element
                childNode.pageRenderer = { totalHeight };

                return ({
                  height: totalHeight,
                  ele: childNode
                });
              });
  return childHeights;
};

/**
 * Checker whether a page break should be applied.
 * When to break a page? https://github.com/nus-mtp/lecture-note-2.0/issues/233
 * @return {boolean}
 */
const shouldPageBreak = function shouldPageBreak(eleHeightArray, index, page) {
  const BREAK_PAGE = true;
  const DO_NOTHING = false;

  const element = eleHeightArray[index].ele;
  const eleName = element.tagName;

  // CSS page-break-before is presence
  const eleStyles = helper.getComputedStyle(element);
  if (eleStyles.pageBreakBefore === 'always') {
    return BREAK_PAGE;
  }

  // Starting of a new topic/chapter
  if (eleName === 'H1') {
    if (page.elements.length > 0) {
      return BREAK_PAGE;
    }
    return DO_NOTHING;
  }

  // Match header tag
  if (eleName.match(/^H\d$/)) {
    // If a header tag is located at the bottom of the page, break page
    const percentRemainingHeight = (page.maxHeight - page.filledHeight)/page.maxHeight;
    if (percentRemainingHeight < 0.18) {
      return BREAK_PAGE;
    }

    // If a header tag is going to be the last element in a page, break page
    const nextHeight = (eleHeightArray[index + 1]) ? eleHeightArray[index + 1].height : 0;
    const selfHeight = eleHeightArray[index].height;
    if (nextHeight + selfHeight + page.filledHeight > page.maxHeight) {
      return BREAK_PAGE;
    }
    return DO_NOTHING;
  }

  if (eleName === 'BR') {
    return (element.getAttribute('page') === '');
  }

  return DO_NOTHING;
};

const addElementToPage = function addElementToPage(page, book, eleArray, index) {
  const element = eleArray[index];
  try {
    if (shouldPageBreak(eleArray, index, page)) {
      // Create a new page if page should be broken here
      page = book.newPage();
    }

    page.add(element);
  } catch (error) {
    if (error.message === 'Element is larger than page') {
      if (page.filledHeight > 0) {
        // if currently not at the beginning of page,
        // create new page before inserting.
        // TODO: Consider breaking element into smaller chunk
        page = book.newPage();
      }
      page.forceAdd(element);
    } else if (error.message === 'Page is full') {
      page = book.newPage();
      page = addElementToPage(page, book, eleArray, index);
    } else {
      throw error;
    }
  }
  return page;
};

export default {
  /**
   * Render pageRenderer's HTML content into its referenceFrame.
   * This function will cause the reflowing of HTML content
   * within the width of the referenceFrame.
   * @return {Promise} promise after the HTML has rendered
   */
  updateReferenceFrame: function updateReferenceFrame(pageRenderer) {
    const rf = pageRenderer.referenceFrame;
    rf.innerHTML = pageRenderer.sourceHTML;

    const pPPFs = pageRenderer.postProcessPromiseFns;

    return Promise.all(pPPFs.map(promiseFn =>
      promiseFn.process.call(promiseFn, rf)
    ))
    .then(() => {
      // Additional element styling
      const imgElements = rf.getElementsByTagName('IMG');
      for (let i = 0; i < imgElements.length; i += 1) {
        helper.overwriteStyle(imgElements[i].style, {
          maxWidth: '100%',
          maxHeight: `${pageRenderer.renderHeight}px`
        });
      }

      // Special check for image loading
      // A similar checking might be needed for external plugin
      return new Promise((resolve, reject) => {
        // TODO: Need to reject promise after timeout
        // in case the images is taking too long to load.

        const imageArray = rf.getElementsByTagName('IMG');
        let countLoadedImage = 0;
        const checkForLoadingCompletion = () => {
          if (countLoadedImage === imageArray.length) {
            resolve(rf);
          }
        };
        const increateLoadedImageCount = () => {
          countLoadedImage += 1;
          checkForLoadingCompletion();
        };
        for (let i = 0; i < imageArray.length; i += 1) {
          // Increment image load count as long as the image is processed.
          imageArray[i].onload = increateLoadedImageCount;
          imageArray[i].onerror = increateLoadedImageCount;

          // Handle case where image does not have a src attribute
          if (!imageArray[i].getAttribute.src) increateLoadedImageCount();
        }
        checkForLoadingCompletion();
      });
    });
  },

  /**
   * Get the height of all elements inside a referenceFrame.
   * @return {array} childHeights - array containing reference to each elements and its respective height
   */
  getChildHeights: function getChildHeights(referenceFrame) {
    const childNodes = referenceFrame.childNodes;
    return constructChildHeightsArray(childNodes);
  },

  /**
   * This function will take in an array of elements with their heights information
   * to organise them into an array of page according to the page size specified.
   * @param {PageRenderer} pageRenderer
   * @param {array} childHeights - array containing reference to each elements and its respective height
   * @return {array} virtualBookPages - array of pages containing references to element
   */
  getPaginationVirtualDom: function getPaginationVirtualDom(pageRenderer, childHeights) {
    const pr = pageRenderer;

    // Create a new book and insert a page into the book
    const virtualBook = new VirtualBook(pr.renderHeight);
    let virtualPage = virtualBook.newPage();

    // Allocate element in pages within the render height
    childHeights.forEach((element, index) => {
      virtualPage = addElementToPage(virtualPage, virtualBook, childHeights, index);
    });

    return virtualBook.pages;
  },

  /**
   *  Render DOM into the renderFrame of the given pageRenderer
   *  @param {PageRenderer} pageRenderer - to supply page sizing and renderFrame
   *  @param {array} virtualBookPages - array of pages containing references to element
   */
  renderPage: function renderPage(pageRenderer, virtualBookPages) {
    if (!pageRenderer || !virtualBookPages) {
      throw new Error('parameters is null or undefined');
    }
    const pr = pageRenderer;

    // Remove all existing children of renderFrame
    while (pr.renderFrame.firstChild) {
      pr.renderFrame.removeChild(pr.renderFrame.firstChild);
    }

    // Recreate pages for renderFrame
    virtualBookPages.forEach((page) => {
      // Create a new page
      const pageDiv = pr.ownerDocument.createElement('DIV');
      pageDiv.setAttribute('class', CLASS_NAME_PREFIX);
      const refStyle = {
        // CSS to set up the page sizing
        width: pr.page.width,
        height: pr.page.height,
        left: `calc(-${pr.page.width} / 2)`,
        paddingTop: pr.page.padding.top,
        paddingBottom: pr.page.padding.bottom,
        paddingLeft: pr.page.padding.left,
        paddingRight: pr.page.padding.right
      };
      helper.overwriteStyle(pageDiv.style, refStyle);

      pr.renderFrame.appendChild(pageDiv);

      // Put content into the page
      page.elements.forEach((node) => {
        pageDiv.appendChild(node.ele);
      });
    });
  },
};