crishellco/vue-hubble

View on GitHub
plugin/src/directive.js

Summary

Maintainability
C
7 hrs
Test Coverage
import { watch } from 'vue';
export const CLOSING_COMMENT = '//';
export const NAMESPACE = 'vue-hubble';
export const ENV_WILDCARD = '*';

const COPY_MESSAGE_RESET_TIMEOUT = 1000;

let $hubble;

export const get = (obj, path, defaultValue) => {
  const travel = (regexp) =>
    String.prototype.split
      .call(path, regexp)
      .filter(Boolean)
      .reduce((res, key) => {
        // istanbul ignore next
        return res !== null && res !== undefined ? res[key] : res;
      }, obj);

  const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/);

  return result === undefined || result === obj ? defaultValue : result;
};

export const inCorrectEnvironment = () => {
  return $hubble.environment.includes(ENV_WILDCARD) || $hubble.environment.includes(process.env.NODE_ENV);
};

export const selectorPickerEnabled = () => {
  return get($hubble, ['enableSelectorPicker'], false);
};

export const getClosingComment = (querySelector) => {
  return `${CLOSING_COMMENT} ${querySelector}`;
};

export const getComponentNamespace = (component, vnode) => {
  const config = get(component.$options, ['hubble'], get(vnode, 'ctx.setupState.hubble', {}));

  return typeof config === 'string' ? config : config.namespace;
};

export const getGenericSelector = (instance, vnode, value) => {
  if (!value) return '';

  const namespaces = [value];
  const enableDeepNamespacing = $hubble.enableDeepNamespacing;
  const namespace = getComponentNamespace(instance, vnode);

  let $component = instance;
  let $vnode;

  if (!enableDeepNamespacing) {
    namespaces.push(namespace);
  } else {
    do {
      $vnode = $component.$el.__vnode;

      const namespace = getComponentNamespace($component, $vnode);

      namespace && namespaces.push(namespace);

      if ($component.$el === $component.$parent.$el) break;

      $component = $component.$parent;
    } while ($component);
  }

  return (
    ($hubble.prefix ? `${$hubble.prefix}--` : '') +
    namespaces
      .filter((namespace) => !!namespace)
      .reverse()
      .join('--')
  );
};

export const getOpeningComment = (querySelector) => {
  return `${querySelector}`;
};

export const getQuerySelector = (selector, selectorType) => {
  const prefix = $hubble.enableGroupedSelectors ? `[${NAMESPACE}]` : '';

  switch (selectorType) {
    case 'class':
      return `${prefix}.${selector}`;
    case 'id':
      return `${prefix}#${selector}`;
    case 'attr':
      return `${prefix}[${selector}]`;
    default:
      return `${prefix}[${selector}]`;
  }
};

export const handleComments = ({ newQuerySelector, oldQuerySelector, element, value, parent }) => {
  const newClosingComment = getClosingComment(newQuerySelector);
  const newOpeningComment = getOpeningComment(newQuerySelector);
  const nodes = parent.childNodes;

  removeExistingCommentElements({ element, nodes, oldQuerySelector, parent });

  /**
   * Add new opening and closing comment elements
   */
  if (value && value.length) {
    const commentAfter = document.createComment(newClosingComment);
    const commentBefore = document.createComment(newOpeningComment);

    parent.insertBefore(commentBefore, element);
    parent.insertBefore(commentAfter, element.nextSibling);
  }
};

export const handleMountedAndUpdated = async (element, { instance, arg, value, oldValue }, vnode) => {
  if (!inCorrectEnvironment()) {
    if (element.hubbleMouseover) {
      document.removeEventListener('mouseover', element.hubbleMouseover);
      element.hubbleMouseover = undefined;
    }

    return;
  }

  if (!element.hubbleMouseover) {
    const id = Math.random().toString(36).substr(2, 11);

    element.hubbleMouseover = handleMouseover(instance, element, id);
    document.addEventListener('mouseover', element.hubbleMouseover);
  }

  arg = arg || $hubble.defaultSelectorType;

  const parent = element.parentElement;
  const newSelector = getGenericSelector(instance, vnode, value);
  const oldSelector = getGenericSelector(instance, vnode, oldValue);
  const newQuerySelector = getQuerySelector(newSelector, arg, instance);
  const oldQuerySelector = getQuerySelector(oldSelector, arg, instance);

  if ($hubble.enableComments && parent) {
    handleComments({ element, newQuerySelector, oldQuerySelector, parent, value });
  } else if (parent) {
    const nodes = parent.childNodes;

    removeExistingCommentElements({ element, nodes, oldQuerySelector, parent });
  }

  handleNamespaceAttribute({ element, newQuerySelector, newSelector, oldSelector });
  handleHubbleSelector({
    arg,
    element,
    newQuerySelector,
    newSelector,
    oldSelector,
  });
};

export const handleHubbleSelector = ({ arg, element, oldSelector, newSelector, newQuerySelector }) => {
  switch (arg) {
    case 'class':
      oldSelector && element.classList.remove(oldSelector);
      if (newSelector) {
        element.classList.add(newSelector);
        element.setAttribute(`${NAMESPACE}-selector`, newQuerySelector);
      }
      break;

    case 'id':
      element.id = newSelector;
      break;

    case 'attr':
      oldSelector && element.removeAttribute(oldSelector);
      if (newSelector) {
        element.setAttributeNode(element.ownerDocument.createAttribute(newSelector));
      }
      break;

    default:
      console.warn(`${arg} is not a valid selector type, using attr instead`);
      oldSelector && element.removeAttribute(oldSelector);
      if (newSelector) {
        element.setAttributeNode(element.ownerDocument.createAttribute(newSelector));
      }
      break;
  }
};

export const handleNamespaceAttribute = ({ element, oldSelector, newSelector, newQuerySelector }) => {
  oldSelector && element.removeAttribute(NAMESPACE);
  element.setAttributeNode(element.ownerDocument.createAttribute(NAMESPACE));
  element.setAttribute(`${NAMESPACE}-selector`, newSelector ? newQuerySelector : '');
};

export const removeExistingCommentElements = ({ nodes, element, parent, oldQuerySelector }) => {
  const oldClosingComment = getClosingComment(oldQuerySelector);
  const oldOpeningComment = getOpeningComment(oldQuerySelector);

  for (let i = 0; i < nodes.length; i++) {
    const nextSibling = nodes[i + 1];
    const prevSibling = nodes[i - 1];

    if (
      nodes[i] === element &&
      nextSibling &&
      nextSibling.nodeType === 8 &&
      nextSibling.textContent === oldClosingComment
    ) {
      parent.removeChild(nextSibling);
    }

    if (
      nodes[i] === element &&
      prevSibling &&
      prevSibling.nodeType === 8 &&
      prevSibling.textContent === oldOpeningComment
    ) {
      parent.removeChild(prevSibling);
    }
  }
};

export const getTooltip = (selector) => {
  return `'${selector}'`;
};

export const addTooltip = (target, id) => {
  const { top, left, width } = target.getBoundingClientRect();
  const selector = target.getAttribute(`${NAMESPACE}-selector`);
  const text = getTooltip(selector);
  const tooltip = document.createElement('span');

  tooltip.style.position = 'fixed';
  tooltip.style.padding = '6px';
  tooltip.style.background = '#374151';
  tooltip.style.borderRadius = '2px';
  tooltip.style.boxShadow = '0 10px 15px -3px rgba(0,0,0,0.1),0 4px 6px -2px rgba(0,0,0,0.05)';
  tooltip.style.color = '#A5B4FC';
  tooltip.style.fontWeight = '400';
  tooltip.style.userSelect = 'all';
  tooltip.style.zIndex = '99999999';
  tooltip.style.cursor = 'pointer';
  tooltip.style.fontSize = '16px';
  tooltip.style.fontFamily = 'monospace';
  tooltip.style.whiteSpace = 'nowrap';
  tooltip.style.textAlign = 'center';
  tooltip.innerText = text;
  tooltip.setAttribute(`${NAMESPACE}-tooltip-id`, id);
  tooltip.setAttributeNode(tooltip.ownerDocument.createAttribute(`${NAMESPACE}-tooltip`));

  document.body.appendChild(tooltip);

  tooltip.style.width = `${tooltip.offsetWidth}px`;
  tooltip.style.left = `${Math.min(
    window.innerWidth - tooltip.offsetWidth,
    Math.max(0, left + width / 2 - tooltip.offsetWidth / 2)
  )}px`;
  tooltip.style.top = `${Math.min(
    window.outerHeight - tooltip.offsetHeight,
    Math.max(0, top - tooltip.offsetHeight)
  )}px`;

  tooltip.addEventListener('click', () => {
    document.execCommand('copy');
    tooltip.innerText = 'Copied!';
    setTimeout(() => {
      tooltip.innerText = text;
    }, COPY_MESSAGE_RESET_TIMEOUT);
  });
};

export const addHighlight = (target, id) => {
  const highlight = document.createElement('div');
  const { top, left, height, width } = target.getBoundingClientRect();

  highlight.style.position = 'fixed';
  highlight.style.width = `${width}px`;
  highlight.style.height = `${height}px`;
  highlight.style.left = `${left}px`;
  highlight.style.top = `${top}px`;
  highlight.style.pointerEvents = 'none';
  highlight.style.zIndex = '99999998';
  highlight.style.background = 'rgba(99, 102, 241, .1)';
  highlight.style.border = '2px solid #6366F1';
  highlight.setAttribute(`${NAMESPACE}-highlight-id`, id);

  document.body.appendChild(highlight);
};

export const handleMouseover = (instance, element, id) => (event) => {
  const { target } = event;
  const oldTooltip = document.querySelector(`[${NAMESPACE}-tooltip-id="${id}"]`);
  const oldHighlight = document.querySelector(`[${NAMESPACE}-highlight-id="${id}"]`);
  const shouldRender = target === element || target === oldTooltip || element.contains(target);

  if (!shouldRender || !selectorPickerEnabled()) {
    oldTooltip && oldTooltip.remove();

    return oldHighlight && oldHighlight.remove();
  }

  if (oldTooltip) return;

  addTooltip(element, id, instance);
  addHighlight(element, id);
};

export const handleCreated = async (element, { instance }, vnode) => {
  !instance.hubbleUnwatch &&
    (instance.hubbleUnwatch = watch(
      $hubble,
      function () {
        instance.$forceUpdate();
      },
      { deep: true }
    ));

  if (!inCorrectEnvironment()) return;

  const id = Math.random().toString(36).substr(2, 11);

  element.hubbleMouseover = handleMouseover(instance, element, id);

  document.addEventListener('mouseover', element.hubbleMouseover);
};

export const handleUnmounted = (element, { instance }) => {
  element.hubbleMouseover && document.removeEventListener('mouseover', element.hubbleMouseover);
  instance.hubbleUnwatch && instance.hubbleUnwatch();
};

export default ($h) => {
  $hubble = $h;

  return {
    created: handleCreated,
    mounted: handleMountedAndUpdated,
    unmounted: handleUnmounted,
    updated: handleMountedAndUpdated,
  };
};