shipshapecode/tether

View on GitHub
src/js/utils/bounds.js

Summary

Maintainability
B
6 hrs
Test Coverage
F
42%
import { defer } from './deferred';
import { extend, uniqueId } from './general';
import { isUndefined } from './type-check';

const zeroPosCache = {};
let zeroElement = null;

export function getBounds(body, el) {
  let doc;
  if (el === document) {
    doc = document;
    el = document.documentElement;
  } else {
    doc = el.ownerDocument;
  }

  const docEl = doc.documentElement;

  const box = _getActualBoundingClientRect(el);

  const origin = _getOrigin(body);

  box.top -= origin.top;
  box.left -= origin.left;

  if (isUndefined(box.width)) {
    box.width = document.body.scrollWidth - box.left - box.right;
  }
  if (isUndefined(box.height)) {
    box.height = document.body.scrollHeight - box.top - box.bottom;
  }

  box.top = box.top - docEl.clientTop;
  box.left = box.left - docEl.clientLeft;
  box.right = doc.body.clientWidth - box.width - box.left;
  box.bottom = doc.body.clientHeight - box.height - box.top;

  return box;
}

/**
 * Gets bounds for when target modifiier is 'scroll-handle'
 * @param target
 * @return {{left: number, width: number, height: number}}
 */
export function getScrollHandleBounds(body, target) {
  let bounds;
  // We have to do the check for the scrollTop and if target === document.body here and set to variables
  // because we may reset target below.
  const targetScrollTop = target.scrollTop;
  const targetIsBody = target === document.body;

  if (targetIsBody) {
    target = document.documentElement;

    bounds = {
      left: pageXOffset,
      top: pageYOffset,
      height: innerHeight,
      width: innerWidth
    };
  } else {
    bounds = getBounds(body, target);
  }

  const style = getComputedStyle(target);

  const hasBottomScroll = (
    target.scrollWidth > target.clientWidth ||
    [style.overflow, style.overflowX].indexOf('scroll') >= 0 ||
    !targetIsBody
  );

  let scrollBottom = 0;
  if (hasBottomScroll) {
    scrollBottom = 15;
  }

  const height = bounds.height - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth) - scrollBottom;

  const out = {
    width: 15,
    height: height * 0.975 * (height / target.scrollHeight),
    left: bounds.left + bounds.width - parseFloat(style.borderLeftWidth) - 15
  };

  let fitAdj = 0;
  if (height < 408 && targetIsBody) {
    fitAdj = -0.00011 * Math.pow(height, 2) - 0.00727 * height + 22.58;
  }

  if (!targetIsBody) {
    out.height = Math.max(out.height, 24);
  }

  const scrollPercentage = targetScrollTop / (target.scrollHeight - height);
  out.top = scrollPercentage * (height - out.height - fitAdj) + bounds.top + parseFloat(style.borderTopWidth);

  if (targetIsBody) {
    out.height = Math.max(out.height, 24);
  }

  return out;
}

/**
 * Gets bounds for when target modifiier is 'visible
 * @param target
 * @return {{top: *, left: *, width: *, height: *}}
 */
export function getVisibleBounds(body, target) {
  if (target === document.body) {
    return { top: pageYOffset, left: pageXOffset, height: innerHeight, width: innerWidth };
  } else {
    const bounds = getBounds(body, target);

    const out = {
      height: bounds.height,
      width: bounds.width,
      top: bounds.top,
      left: bounds.left
    };

    out.height = Math.min(out.height, bounds.height - (pageYOffset - bounds.top));
    out.height = Math.min(out.height, bounds.height - ((bounds.top + bounds.height) - (pageYOffset + innerHeight)));
    out.height = Math.min(innerHeight, out.height);
    out.height -= 2;

    out.width = Math.min(out.width, bounds.width - (pageXOffset - bounds.left));
    out.width = Math.min(out.width, bounds.width - ((bounds.left + bounds.width) - (pageXOffset + innerWidth)));
    out.width = Math.min(innerWidth, out.width);
    out.width -= 2;

    if (out.top < pageYOffset) {
      out.top = pageYOffset;
    }
    if (out.left < pageXOffset) {
      out.left = pageXOffset;
    }

    return out;
  }
}

export function removeUtilElements(body) {
  if (zeroElement) {
    body.removeChild(zeroElement);
  }
  zeroElement = null;
}

/**
 * Same as native getBoundingClientRect, except it takes into account parent <frame> offsets
 * if the element lies within a nested document (<frame> or <iframe>-like).
 * @param node
 */
function _getActualBoundingClientRect(node) {
  let boundingRect = node.getBoundingClientRect();

  // The original object returned by getBoundingClientRect is immutable, so we clone it
  // We can't use extend because the properties are not considered part of the object by hasOwnProperty in IE9
  let rect = {};
  for (let k in boundingRect) {
    rect[k] = boundingRect[k];
  }

  try {
    if (node.ownerDocument !== document) {
      let { frameElement } = node.ownerDocument.defaultView;
      if (frameElement) {
        let frameRect = _getActualBoundingClientRect(frameElement);
        rect.top += frameRect.top;
        rect.bottom += frameRect.top;
        rect.left += frameRect.left;
        rect.right += frameRect.left;
      }
    }
  } catch (err) {
    // Ignore "Access is denied" in IE11/Edge
  }

  return rect;
}

function _getOrigin(body) {
  // getBoundingClientRect is unfortunately too accurate.  It introduces a pixel or two of
  // jitter as the user scrolls that messes with our ability to detect if two positions
  // are equivilant or not.  We place an element at the top left of the page that will
  // get the same jitter, so we can cancel the two out.
  let node = zeroElement;
  if (!node || !body.contains(node)) {
    node = document.createElement('div');
    node.setAttribute('data-tether-id', uniqueId());
    extend(node.style, {
      top: 0,
      left: 0,
      position: 'absolute'
    });

    body.appendChild(node);

    zeroElement = node;
  }

  const id = node.getAttribute('data-tether-id');
  if (isUndefined(zeroPosCache[id])) {
    zeroPosCache[id] = _getActualBoundingClientRect(node);

    // Clear the cache when this position call is done
    defer(() => {
      delete zeroPosCache[id];
    });
  }

  return zeroPosCache[id];
}