代码改变世界

骨架屏生成辅助函数

2022-12-26 10:59  前端小白的江湖路  阅读(125)  评论(0编辑  收藏  举报
/**
 * @description: A simple function to help you generate skeleton
 * Demo:
 *  const s = new Skeleton();
    const rootNode = document.querySelector('#root') as HTMLElement;
    const classList = [  // give className in which you wanna generate
      'module-icon',
      'module-title',
      'module-input',
      'module-btn-others-title-content',
      'module-btn-others-btn',
      'module-btn',
    ];
    const { html, style } = s.traverse({
      rootNode,
      classList,
      animation: "wave"
    }); 
    s.appendTo(html, style);
 * 
 */

const ANIMATION_TYPE_MAP = {
  WAVE: "wave",
  NONE: "none"
} as const;

type AnimationValueType = typeof ANIMATION_TYPE_MAP[keyof typeof ANIMATION_TYPE_MAP];

interface SkeletonOptionsType {
  rootNode: HTMLElement,

  classList: Array<string>,

  animation?: AnimationValueType,  // default is wave

  isFilterOutsideElements?: boolean;   // Whether to filter elements outside the screen, default is true

  borderRadiusFit?: "auto" | "fit-content";  // auto means if border-radius is less than width/2, use minBorderRadius, default is auto

  minBorderRadius?: number;  // min border radius, default is 3 
}

interface StyleType {
  position: "absolute",
  left: string;
  top: string;
  width: string;
  height: string;
  background: string;
  borderRadius: string;
  overflow: "hidden"
}

const DIRECTION = {
  HORIZONTAL: "HORIZONTAL",
  VERTICAL: "VERTICAL",
} as const;

type DirectionType = keyof typeof DIRECTION;


const KEYFRAMES_MAP = {
  WAVE: `@keyframes kf-wave {
    0% {
      transform: translateX(-100%);
    }
  
    50% {
      transform: translateX(100%);
    }
  
    100% {
      transform: translateX(100%);
    }
  }`
};

const ANIMATION_STYLE_MAP = {
  WAVE: `animation: kf-wave 1.6s linear 0.5s infinite;
  background: linear-gradient( 90deg, transparent, rgba(0, 0, 0, 0.04), transparent );
  content: '';
  position: absolute;
  transform: translateX(-100%);
  bottom: 0;
  left: 0;
  right: 0;
  top: 0;`
}

const BORDER_RADIUS_MAP = {
  AUTO: "auto",
  FIT_CONTENT: "fit-content",
}


class Skeleton {
  traverse(skeletonOptions: SkeletonOptionsType) {
    const { 
      rootNode, 
      classList, animation = ANIMATION_TYPE_MAP.WAVE, 
      borderRadiusFit=BORDER_RADIUS_MAP.AUTO, 
      minBorderRadius = 3,
      isFilterOutsideElements=true
    } =  skeletonOptions;
    const targetClassMap = this.generateClassMap(classList);

    const backTrack = (node) => {
      const children = node.children ?? [];
      const htmlList: Array<string> = [];
      let styleList: Array<string>= [];
      for (let child of children) {
        const { x, y, width, height, } = child.getBoundingClientRect();
        let style: StyleType | {} = {};
        if (this.isClassIntersect(child.classList, targetClassMap)) {
          if(isFilterOutsideElements && !this.isElementInView(x, y)) {
            continue;
          }
          style = {
            position: "absolute",
            left: this.transformToPercent(x, DIRECTION.HORIZONTAL),
            top: this.transformToPercent(y, DIRECTION.VERTICAL),
            width: this.transformToPercent(width, DIRECTION.HORIZONTAL),
            height: this.transformToPercent(height, DIRECTION.VERTICAL),
            background: "#F1F1F1",
            borderRadius: this.getBorderRadius(window.getComputedStyle(child)["border-radius"], borderRadiusFit, minBorderRadius, width),
            overflow: "hidden",
          }

          const className = child.classList[0] + "-skeleton";
          style = this.generateStyle(style);
          styleList.push(`.${className} {${style}}`);

          if(ANIMATION_TYPE_MAP.NONE !== animation) {
            styleList.push(`.${className}::after {${ANIMATION_STYLE_MAP[animation.toUpperCase()]}}`)
          }

          htmlList.push(`<div class=${className}>`);
          const childTarget = backTrack(child);
          htmlList.push(childTarget.htmlList.join(""));
          styleList.push(childTarget.styleList.join(""));
          htmlList.push("</div>");
          
        } else {
          const childTarget = backTrack(child);
          htmlList.push(childTarget.htmlList.join(""));
          styleList.push(childTarget.styleList.join(""));
        }
      }
      return {
        htmlList,
        styleList,
      };
    }

    const { htmlList, styleList } = backTrack(rootNode);
    let animationStyle = "";
    if(animation === ANIMATION_TYPE_MAP.WAVE) {
      animationStyle = `${KEYFRAMES_MAP.WAVE}`;
    }
    return {
      html: `<div className="i-skeleton">${htmlList.join("")}</div>`,
      style: animationStyle + styleList.join("")
    }
  }

  appendTo (html: string, style: string) :void {
    console.log(html);
    console.log(style)
    const styleElement = document.createElement("style");
    styleElement.innerHTML = style;
    document.head.appendChild(styleElement);
    document.body.innerHTML = html;
  }

  getBorderRadius (currentBorderRadius: string | number, borderRadiusMod: string, minBorderRadius: number, width: number) :string {
    currentBorderRadius = Number.parseInt(currentBorderRadius as string);
    let borderRadius = minBorderRadius;
    switch (borderRadiusMod) {
      case BORDER_RADIUS_MAP.AUTO:
        if(currentBorderRadius < width / 2 - 1) {
          borderRadius = minBorderRadius;
        }
        break;
      case BORDER_RADIUS_MAP.FIT_CONTENT:
        borderRadius = currentBorderRadius;
        break;
      default:
        break;
    }
    return `${borderRadius}px`
  }

  generateClassMap(classList: Array<string>) :Map<string, boolean> {
    const map = new Map();
    for (let item of classList) {
      map.set(item, true);
    }
    return map;
  }

  isClassIntersect(classList: Array<string>, targetMap: Map<string, boolean>) {
    for (let item of classList) {
      if (targetMap.has(item)) {
        return true;
      }
    }
    return false;
  }

  isElementInView (x: number, y: number) :boolean {
    if(x > 0 && 
      x < document.documentElement.clientWidth && 
      y > 0 && 
      y < document.documentElement.clientHeight) {
      return true;
    }

    return false;
  }

  generateStyle(style): string {
    let result = "";
    for (let [key, value] of Object.entries(style)) {
      const keyWithMiddleLine = this.camelToMiddleLine(key);
      result += `${keyWithMiddleLine}: ${value};`;
    }
    return result;
  }

  camelToMiddleLine(str: string): string {
    const p = /\B([A-Z])/g;
    return str.replace(p, '-$1').toLowerCase()
  }

  transformToPercent(distance: number, direction: DirectionType): string {
    const viewportWidth = document.documentElement.clientWidth;
    const viewportHeight = document.documentElement.clientHeight;
    switch (direction) {
      case DIRECTION.HORIZONTAL:
        return (distance / viewportWidth * 100).toFixed(1) + "%"
        break;
      default:
        return (distance / viewportHeight * 100).toFixed(1) + "%"
    }
  }
}

export default Skeleton;