利用这个css属性,你也能轻松实现一个新手引导库open in new window

相信大家或多或少都在各种网站上使用过新手引导,当网站提供的功能有点复杂时,这是一个对新手非常友好的功能,可以跟随新手引导一步一步了解网站的各种功能,我们要做的只是点击下一步或者上一步,网站就能滚动到指定位置,然后高亮页面的一部分,并且配以一些图文介绍。

目前有很多帮你实现这种功能的开源库,当然,自己实现一个也不难,而且核心就是一个简单的css样式,不信你接着往下看。

基本思路

假设我们的新手引导库是一个类,名为NoviceGuide,我们可以这样使用它:

new NoviceGuide({
    steps: [
        {
            element: '',// 页面上的元素,可以是节点,也可以是节点的选择器
            text: '我是第一步',
            img: '我是第一步的图片'
        },
        {
            element: '',
            text: '我是第二步'
        }
    ]
}).start()

我们稍微思考一下就会发现,实现原理其实很简单,只要找到某一步指定节点的位置和宽高,然后将页面滚动到该节点的位置,最后高亮它,并且在旁边显示信息即可。

我们的类基本结构如下:

class NoviceGuide {
  constructor(options) {
    this.options = options
    // 步骤数据
    this.steps = []
    // 当前所在步骤
    this.currentStepIndex = -1
    // 处理步骤数据
    this.initSteps()
  }

  initSteps() {
    this.options.steps.forEach((step) => {
      this.steps.push({
        ...step,
        element:
          typeof step.element === "string"
            ? document.querySelector(step.element)
            : step.element,
      })
    })
  }

  start() {
    this.next()
  }

  next() {}
}

滚动到目标元素

获取到当前步骤的元素,然后再获取它的位置,最后再滚动页面,让目标元素居中即可。

class NoviceGuide {
    next() {
        // 已经是最后一步,那么结束引导
        if (this.currentStepIndex + 1 >= this.steps.length) {
          return this.done()
        }
        this.currentStepIndex++
        this.to()
    }

    to() {
        // 当前步骤
        const currentStep = this.steps[this.currentStepIndex]
        // 当前步骤元素的尺寸和位置信息
        const rect = currentStep.element.getBoundingClientRect()
        const windowHeight = window.innerHeight
        // 浏览器窗口滚动到元素所在位置
        window.scrollBy(0, rect.top - (windowHeight / 2 - rect.height / 2))
    }

    done() {}
}

使用window.scrollBy滚动相对距离

不过如果元素已经在可视窗口内,其实不需要将它居中,否则如果多个步骤都在一个窗口内,那么切换步骤会频繁的滚动页面,体验反而不好,所以先判断一下元素是否在视口内:

class NoviceGuide {
    to() {
        const currentStep = this.steps[this.currentStepIndex]
        const rect = currentStep.element.getBoundingClientRect()
        const windowHeight = window.innerHeight
        if (!this.elementIsInView(currentStep.element)) {
            window.scrollBy(0, rect.top - (windowHeight - rect.height) / 2)
        }
    }

    elementIsInView(el) {
        const rect = el.getBoundingClientRect()
        return (
            rect.top >= 0 &&
            rect.left >= 0 &&
            rect.bottom <= window.innerHeight &&
            rect.right <= window.innerWidth
        )
    }
}

高亮元素

目标元素可见了,接下来要做的是高亮它,具体的效果就是页面上只有目标元素是亮的,其他地方都是暗的,这个实现方式我考虑过使用svg、canvas等,比如canvas实现:

class NoviceGuide {
    to() {
        // ...
        this.highlightElement(currentStep.element)
    }

    highlightElement(el) {
        const rect = el.getBoundingClientRect();
        const canvas = document.createElement('canvas')
        document.body.appendChild(canvas)
        const ctx = canvas.getContext('2d')
        canvas.width = window.innerWidth
        canvas.height = window.innerHeight
        canvas.style.cssText = `
            position: fixed;
            left: 0;
            top: 0;
            z-index: 99999999;
            `
        ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
        ctx.fillRect(0, 0, window.innerWidth, window.innerHeight)
        ctx.clearRect(rect.left, rect.top, rect.width, rect.height)
    }
}

原理很简单,创建一个和窗口一样大的canvas,然后全部填充成半透明,最后再清除掉目标元素所在位置的绘制,就达到了高亮的效果

不过这种方式想要效果更好一点比较麻烦,后来在其他库中看到一个很简单的实现,使用一个box-shadow属性即可:

class NoviceGuide {
    highlightElement(el) {
        const rect = el.getBoundingClientRect()
        if (!this.highlightEl) {
            this.highlightEl = document.createElement("div")
            this.highlightEl.style.cssText = `
                position: absolute;
                box-shadow: 0 0 0 5000px rgba(0, 0, 0, 0.5);
                z-index: 99999999;
                border-radius: 5px;
                transition: all 0.3s ease-out;
                `
            document.body.appendChild(this.highlightEl)
        }
        this.highlightEl.style.left = rect.left + window.pageXOffset + "px"
        this.highlightEl.style.top = rect.top + window.pageYOffset + "px"
        this.highlightEl.style.width = rect.width + "px"
        this.highlightEl.style.height = rect.height + "px"
    }
}

核心就是box-shadow: 0 0 0 5000px rgba(0, 0, 0, 0.5);这一行样式,创建一个和目标元素一样大小的元素,然后盖在它上面,然后把这个元素的阴影大小设置成非常大,这样除了这个元素的内部,页面其他地方都是它的阴影,就达到了高亮的效果,果然是css学的好,每天下班早。

使用DOM简单很多,修改样式比较方便,另外只要设置transition,就能轻松实现切换步骤时高亮的过渡动画效果。

另外为什么这里没有使用固定定位,而是使用绝对定位,其实是因为如果使用固定定位,页面可以滚动,但是高亮框并不会滚动,那么就对不上了。

切换步骤

接下来,我们创建一个新元素用来存放信息和上一步下一步的按钮:

class NoviceGuide {
    constructor(options) {
        // ...
        this.infoEl = null
    }

    to() {
        // ...
        this.showStepInfo(currentStep)
    }

    showStepInfo(step) {
        if (!this.infoEl) {
            this.infoEl = document.createElement("div")
            this.infoEl.style.cssText = `
                position: absolute;
                z-index: 99999999;
    background-color: #fff;
    border-radius: 5px;
                `
            document.body.appendChild(this.infoEl)
            // 绑定单击事件
            this.infoEl.addEventListener("click", (e) => {
                let type = e.target.getAttribute("data-type")
                if (type) {
                    if (type === "prev") {
                        this.prev()
                    }
                    if (type === "next") {
                        this.next()
                    }
                }
            })
        }
        this.infoEl.innerHTML = `
            <div>
                ${
                     step.img ? `<div>
                        <img src="${step.img}" style="width: 250px" />
                     </div>` : ''
                }
                <div>${step.text}</div>
            </div>
            <div>
                <button data-type="prev">上一步</button>
                <button data-type="next">下一步</button>
            </div>
            `
        const rect = step.element.getBoundingClientRect()
        this.infoEl.style.left = rect.left + window.pageXOffset + "px"
        this.infoEl.style.top = rect.bottom + window.pageXOffset + "px"
    }
}

很简单,同样是创建一个绝对定位的元素,里面存放信息、图片、按钮,然后监听一下点击事件,判断点击的是上一步还是下一步,补充一下上一步和结束的逻辑:

class NoviceGuide {
    prev() {
        if (this.currentStepIndex - 1 < 0) {
            return
        }
        this.currentStepIndex--
        this.to()
    }

    done() {
        document.body.removeChild(this.highlightEl)
        document.body.removeChild(this.infoEl)
        this.currentStepIndex = -1
    }
}

结束的话直接删除创建的两个元素就可以了

优化

加点内边距

目前视觉上不是很好看,高亮框和目标元素大小是完全一样的,高亮框和信息框完全挨着,信息框没有内边距,所以优化一下:

class NoviceGuide {
    constructor(options) {
        this.options = Object.assign(
            {
                padding: 10,
                margin: 10
            },
            options
        )
    }

    highlightElement(el) {
        // ...
        let { padding } = this.options
        this.highlightEl.style.left = rect.left + window.pageXOffset - padding + "px"
        this.highlightEl.style.top = rect.top + window.pageYOffset - padding + "px"
        this.highlightEl.style.width = rect.width + padding * 2 + "px"
        this.highlightEl.style.height = rect.height + padding * 2 + "px"
    }

    showStepInfo(step) {
        let { padding, margin } = this.options
         if (!this.infoEl) {
             this.infoEl.style.cssText = `
    padding: ${padding}px;
   `
         }
        // ...
        this.infoEl.style.left = rect.left + window.pageXOffset - padding + "px"
       this.infoEl.style.top = rect.bottom + window.pageYOffset + padding + margin + "px"
    }
}

支持某个步骤没有元素

某些步骤可能是纯信息,不需要元素,这种情况直接显示在页面中间即可:

class NoviceGuide {
    to() {
        const currentStep = this.steps[this.currentStepIndex]
        if (!currentStep.element) {
            // 当前步骤没有元素
            this.highlightElement()
            this.showStepInfo(currentStep)
            return
        }
        // ...
    }

    highlightElement(el) {
        // ...
        if (el) {
            const rect = el.getBoundingClientRect()
            let { padding } = this.options
            // ...
            // 原有逻辑
        } else {
            // 当前步骤没有元素高亮元素的宽高设置成0,并且直接定位在窗口中间
            this.highlightEl.style.left = window.innerWidth / 2 + window.pageXOffset + "px"
            this.highlightEl.style.top = window.innerHeight / 2 + window.pageYOffset + "px"
            this.highlightEl.style.width = 0 + "px"
            this.highlightEl.style.height = 0 + "px"
        }
    }

    showStepInfo(step) {
        // ...
        if (step.element) {
            const rect = step.element.getBoundingClientRect()
            // ...
            // 原有逻辑
        } else {
            // 当前步骤没有元素,信息框定位在窗口中间
            const rect = this.infoEl.getBoundingClientRect()
            this.infoEl.style.left = (window.innerWidth - rect.width) / 2 + window.pageXOffset + "px"
            this.infoEl.style.top = (window.innerHeight - rect.height) / 2 + window.pageYOffset + "px"
        }
    }
}

当然,上述实现还是有点问题的,比如网速慢的时候,或者图片比较大时,图片还没加载出来,那么获取到的信息框的大小是不对的,导致定位会出现偏差,这个问题本文就不考虑了。

动态计算信息的位置

目前我们的信息框是默认显示在高亮元素下方的,这样显然是有问题的,比如高亮元素刚好在屏幕底部,或者信息框的高度很高,底部无法完全显示,这种情况,我们就需要改成动态计算的方式,具体来说就是依次判断信息框能否在高亮元素下方、上方、左方、右方四个方向显示,如果都不行的话,还要尝试调整页面滚动的位置使高亮框和信息框都能显示。

class NoviceGuide {
    showStepInfo(step) {
        // ...
        if (step.element) {
            this.computeInfoPosition(step)
        } else {
            // ...
        }
    }
}

计算的逻辑我们放到一个新函数里:

class NoviceGuide {
    computeInfoPosition(step) {
        const { padding, margin } = this.options
        const windowWidth = window.innerWidth
        const windowHeight = window.innerHeight
        const windowPageXOffset = window.pageXOffset
        const windowPageYOffset = window.pageYOffset
        const rect = step.element.getBoundingClientRect()
        const infoRect = this.infoEl.getBoundingClientRect()
        // ...
    }
}

获取和保存一些基本信息,继续:

class NoviceGuide {
    computeInfoPosition(step) {
        let left = 0
        let top = 0
        const adjustLeft = () => {
            // 优先和高亮框左对齐
            if (windowWidth - rect.left - padding >= infoRect.width) {
                return rect.left - padding + windowPageXOffset
            } else {
                // 否则水平居中显示
                return (windowWidth - infoRect.width) / 2 + windowPageXOffset
            }
        };
        if (
            rect.bottom + padding + margin + infoRect.height <= windowHeight && // 下方宽度可以容纳
            infoRect.width <= windowWidth // 信息框宽度比浏览器窗口小
        ) {
            // 可以在下方显示
            left = adjustLeft()
            top = rect.bottom + padding + margin + windowPageYOffset
        } else if (
            rect.top - padding - margin >= infoRect.height &&
            infoRect.width <= windowWidth
        ) {
            // 可以在上方显示
            left = adjustLeft()
            top = rect.top - padding - margin - infoRect.height + windowPageYOffset
        }
        // 省略后续两个判断
    }
}

判断高亮框的下方和上方的剩余空间能否容纳信息框,另外还要判断一下信息框的宽度是否比浏览器窗口小。

对于信息框的水平位置,我们优先让它和高亮框左对齐,如果空间不够,那么就让信息框在浏览器窗口水平居中。

对于左侧和右侧的判断也是类似的,完整代码可以去文末的仓库里查看。

当上下左右四个方向都无法满足条件时,我们还可以再检查一种情况,也就是高亮框和信息框的总高度是否比浏览器窗口高度小,是的话我们可以通过滚动页面位置来达到完整显示的目的:

class NoviceGuide {
    computeInfoPosition(step) {
        // ...
        else {
            // 否则检查高亮框高度+信息框高度是否小于窗口高度
            let totalHeightLessThenWindow =
                rect.height + padding * 2 + margin + infoRect.height <= windowHeight
            if (
                totalHeightLessThenWindow &&
                Math.max(rect.width + padding * 2, infoRect.width) <= windowWidth
            ) {
                // 上下排列可以放置
                // 滚动页面,居中显示两者整体
                let newTop =
                    (windowHeight -
                     (rect.height + padding * 2 + margin + infoRect.height)) /
                    2
                window.scrollBy(0, rect.top - newTop)
            } else {
                // 恕我无能为力
                // 回到默认位置
            }
            left = adjustLeft()
            top = rect.bottom + padding + margin + windowPageYOffset
        }
        this.infoEl.style.left = left + "px"
        this.infoEl.style.top = top + "px"
    }
}

如果总高度小于窗口高度,那么可以调整页面滚动位置,否则就不做任何处理,这两种情况对于信息框来说,都是显示在高亮框下方。

如果目标元素位于可滚动元素内

这个问题是什么意思呢,比如我们想高亮下图中红框内的元素:

它所在的可滚动父元素并不是document.body,事实上这个页面body元素压根无法滚动,宽高是和窗口宽高一致的,而我们的实现逻辑是通过滚动body来使元素可见的,那么我们就做不到让这个元素出现在视口。

解决这个问题可以这么考虑,我们先找到目标元素的最近的可滚动的祖先元素,如果元素不在该祖先元素的可视区域内,那么就滚动父元素让元素可见,当然这样还没完,因为该祖先元素也可能存在一个可滚动的祖先元素,它也不一定是在它的祖先元素内可见,所以还得判断和让它可见,很明显,这是一个向上递归的过程,一直检查到body元素为止。

先来写一个获取最近的可滚动祖先元素的方法:

class NoviceGuide {
    getScrollAncestor(el) {
        let style = window.getComputedStyle(el)
        const isAbsolute = style.position === 'absolute'
        const isFixed = style.position === 'fixed'
        const reg = /(auto|scroll)/
        // 如果元素是固定定位,那么可滚动祖先元素为body
        if (isFixed) return document.body
        let parent = el.parentElement
        while (parent) {
            style = window.getComputedStyle(parent)
            // 如果是绝对定位,那么可滚动的祖先元素必须是有定位的才行
            if (!(isAbsolute && style.position === 'static')) {
                // 如果某个祖先元素的overflow属性为auto或scroll则代表是可滚动的
                if (reg.test(style.overflow + style.overflowX + style.overflowY)) {
                    return parent
                }
            }
            parent = parent.parentElement
        }
        return document.body
    }
}

就是不断向上递归,接下来修改一下to方法,在获取目标元素尺寸位置信息之前先让它可见:

class NoviceGuide {
    to() {
        // ...
        this.scrollAncestorToElement(currentStep.element)
        const rect = currentStep.element.getBoundingClientRect()
        // ...
    }

    scrollAncestorToElement(element) {
        // 获取可滚动的祖先元素
        const parent = this.getScrollAncestor(element)
        if (parent === document.body) return
        // 祖先元素和目标元素的尺寸位置信息
        let parentRect = parent.getBoundingClientRect()
        let rect = element.getBoundingClientRect()
        // 滚动祖先元素,让目标元素可见
        parent.scrollTop = parent.scrollTop + rect.top - parentRect.top
        // 继续向上递归
        this.scrollAncestorToElement(parent)
    }
}
完整代码
// HighlightElement.ts
import NoviceGuide, { Step } from '../index'
import { prefix } from './utils';

// 高亮元素类
export default class HighlightElement {
  public el: HTMLElement
  constructor(public app: NoviceGuide) {
    this.app = app
    this.el = null
  }

  // 显示高亮元素
  show(step: Step) {
    if (!this.el) {
      this.createEl()
    }
    let left = 0,
      top = 0,
      width = 0,
      height = 0
    if (step.element) {
      const rect = step.element.getBoundingClientRect()
      let { padding } = this.app.options
      left = rect.left + window.pageXOffset - padding
      top = rect.top + window.pageYOffset - padding
      width = rect.width + padding * 2
      height = rect.height + padding * 2
    } else {
      // 当前步骤没有元素则宽高设为0,然后窗口居中显示
      left = window.innerWidth / 2 + window.pageXOffset
      top = window.innerHeight / 2 + window.pageYOffset
      width = 0
      height = 0
    }
    this.el.style.left = left + 'px'
    this.el.style.top = top + 'px'
    this.el.style.width = width + 'px'
    this.el.style.height = height + 'px'
  }

  // 创建高亮元素
  createEl() {
    let { boxShadowColor, transition, borderRadius, highlightElClass, zIndex } =
      this.app.options
    this.el = document.createElement('div')
    this.el.className = prefix + 'highlight-el'
    this.el.style.cssText = `
        box-shadow: 0 0 0 5000px ${boxShadowColor};
        border-radius: ${borderRadius};
        transition: ${transition};
        z-index: ${zIndex};
    `
    if (highlightElClass) {
      this.el.classList.add(highlightElClass)
    }
    document.body.appendChild(this.el)
  }

  // 移除高亮元素
  removeEl() {
    if (this.el) {
        document.body.removeChild(this.el)
        this.el = null
    }
  }
}
// InfoElement.ts
import NoviceGuide, { Step } from '../index'
import { loadImage, prefix } from './utils'

// 信息元素类
export default class HighlightElement {
  public el: HTMLElement
  constructor(public app: NoviceGuide) {
    this.app = app
    this.el = null
    this.app.on('after-step-change', this.onStepChange.bind(this))
  }

  // 显示信息框
  async show(step: Step) {
    if (this.app.options.useCustomInfo && this.app.options.getCustomInfoEl) {
      // 自定义信息框
      let el = await this.app.options.getCustomInfoEl(step)
      let res = this.getInfoRect(step, el)
      el.style.left = res.left + 'px'
      el.style.top = res.top + 'px'
    } else {
      // 内置信息框
      await this.showInnerInfo(step)
    }
  }

  // 显示内置信息框
  async showInnerInfo(step: Step) {
    if (!this.el) {
      this.createEl()
    }
    if (step.img) {
      try {
        await loadImage(step.img)
      } catch (error) {
        console.error(error)
      }
    }
    this.el.innerHTML = this.createHTML(step)
    let res = this.getInfoRect(step, this.el)
    this.el.style.left = res.left + 'px'
    this.el.style.top = res.top + 'px'
  }

  // 计算信息框的位置
  getInfoRect(step: Step, el: HTMLElement) {
    if (step.element) {
      return this.computeInfoPosition(step, el)
    } else {
      // 当前没有元素,则信息元素直接窗口居中显示
      const rect = el.getBoundingClientRect()
      return {
        left: (window.innerWidth - rect.width) / 2 + window.pageXOffset,
        top: (window.innerHeight - rect.height) / 2 + window.pageYOffset
      }
    }
  }

  // 创建内置信息框的内容
  createHTML(step: Step) {
    let { prevText, nextText, showIndicator } = this.app.options
    return `
    <div class="${prefix}info-el-header">
      <div class="${prefix}info-el-title">${step.title || ''}</div>
      <div class="${prefix}info-el-close" data-type="close">×</div>
    </div>
    <div class="${prefix}info-el-info">
      ${
        step.img
          ? `<img class="${prefix}info-el-info-img" src="${step.img}" />`
          : ''
      }
      <div class="${prefix}info-el-info-text">${step.text || ''}</div>
    </div>
    <div class="${prefix}info-el-indicator">
      ${
        showIndicator
          ? this.app.steps
              .map((_, index) => {
                return `<div class="${prefix}info-el-indicator-item ${
                  index === this.app.currentStepIndex ? 'active' : ''
                }" data-type="indicator" data-index="${index}"></div>`
              })
              .join('\n')
          : ''
      }
    </div>
    <div class="${prefix}info-el-btn-group">
      <div class="${prefix}info-el-btn ${prefix}info-el-btn-prev ${
      this.app.isFirstStep() ? 'disabled' : ''
    }" data-type="prev">${prevText}</div>
      <div class="${prefix}info-el-btn ${prefix}info-el-btn-next" data-type="next">${nextText}</div>
    </div>
  `
  }

  // 创建内置信息框元素
  createEl() {
    let { padding, borderRadius, backgroundColor, infoElClass, zIndex } =
      this.app.options
    this.el = document.createElement('div')
    this.el.className = prefix + 'info-el'
    this.el.style.cssText = `
      background-color: ${backgroundColor}; 
      padding: ${padding}px;
      border-radius: ${borderRadius};
      z-index: ${zIndex};
    `
    if (infoElClass) {
      this.el.classList.add(infoElClass)
    }
    document.body.appendChild(this.el)
    this.el.addEventListener('click', this.onClick.bind(this))
  }

  // 内置信息框的点击事件
  onClick(e: MouseEvent) {
    let type = (e.target as HTMLElement).getAttribute('data-type')
    switch (type) {
      case 'close':
        this.app.done()
        break
      case 'prev':
        this.app.prev()
        break
      case 'next':
        this.app.next()
        break
      case 'indicator':
        let index = (e.target as HTMLElement).getAttribute('data-index')
        if (!Number.isNaN(Number(index))) {
          this.app.jump(Number(index))
        }
        break
      default:
        break
    }
  }

  // 移除内置信息框
  removeEl() {
    if (this.el) {
      document.body.removeChild(this.el)
      this.el = null
    }
  }

  // 更新内置信息框的状态
  onStepChange(stepIndex: number) {
    let { nextText, completeText, useCustomInfo } = this.app.options
    if (useCustomInfo) return

    // 更新按钮样式和文字
    let prevEl = document.querySelector(`.${prefix}info-el-btn-prev`)
    let nextEl = document.querySelector(`.${prefix}info-el-btn-next`)
    prevEl.classList.remove('disabled')
    nextEl.textContent = nextText
    if (this.app.isFirstStep()) {
      prevEl.classList.add('disabled')
    }
    if (this.app.isLastStep()) {
      nextEl.textContent = completeText
    }

    // 更新指示器
    let indicatorEls = Array.from(
      document.querySelectorAll(`.${prefix}info-el-indicator-item`)
    )
    indicatorEls.forEach(item => {
      if (item.classList.contains('active')) {
        item.classList.remove('active')
      }
    })
    if (indicatorEls[stepIndex]) {
      indicatorEls[stepIndex].classList.add('active')
    }
  }

  // 动态计算信息框的位置
  computeInfoPosition(step: Step, el: HTMLElement) {
    const { padding, margin } = this.app.options
    const windowWidth = window.innerWidth
    const windowHeight = window.innerHeight
    const windowPageXOffset = window.pageXOffset
    const windowPageYOffset = window.pageYOffset
    const rect = step.element.getBoundingClientRect()
    const infoRect = el.getBoundingClientRect()
    let left = 0
    let top = 0
    const adjustLeft = () => {
      // 优先和高亮框左对齐
      if (windowWidth - rect.left - padding >= infoRect.width) {
        return rect.left - padding + windowPageXOffset
      } else if (rect.right + padding >= infoRect.width) {
        // 次优先和高亮框右对齐
        return rect.right + padding - infoRect.width + windowPageXOffset
      } else {
        // 否则水平居中显示
        return (windowWidth - infoRect.width) / 2 + windowPageXOffset
      }
    }
    const adjustTop = () => {
      // 优先和高亮框上对齐
      if (windowHeight - rect.top - padding >= infoRect.height) {
        return rect.top - padding + windowPageYOffset
      } else if (rect.bottom + padding >= infoRect.height) {
        // 次优先和高亮框下对齐
        return rect.bottom + padding - infoRect.height + windowPageYOffset
      } else {
        // 否则水平居中显示
        return (windowHeight - infoRect.height) / 2 + windowPageYOffset
      }
    }
    if (
      rect.bottom + padding + margin + infoRect.height <= windowHeight && // 下方宽度可以容纳
      infoRect.width <= windowWidth // 信息框宽度比浏览器窗口小
    ) {
      // 可以在下方显示
      left = adjustLeft()
      top = rect.bottom + padding + margin + windowPageYOffset
    } else if (
      rect.top - padding - margin >= infoRect.height &&
      infoRect.width <= windowWidth
    ) {
      // 可以在上方显示
      left = adjustLeft()
      top = rect.top - padding - margin - infoRect.height + windowPageYOffset
    } else if (
      rect.left - padding - margin >= infoRect.width &&
      infoRect.height <= windowHeight
    ) {
      // 可以在左方显示
      left = rect.left - padding - margin - infoRect.width + windowPageXOffset
      top = adjustTop()
    } else if (
      rect.right + padding + margin + infoRect.width <= windowWidth &&
      infoRect.height <= windowHeight
    ) {
      // 可以在右方显示
      left = rect.right + padding + margin + windowPageXOffset
      top = adjustTop()
    } else {
      // 否则检查高亮框高度+信息框高度是否小于窗口高度
      let totalHeightLessThenWindow =
        rect.height + padding * 2 + margin + infoRect.height <= windowHeight
      if (
        totalHeightLessThenWindow &&
        Math.max(rect.width + padding * 2, infoRect.width) <= windowWidth
      ) {
        // 上下排列可以放置
        // 滚动页面,居中显示两者整体
        let newTop =
          (windowHeight -
            (rect.height + padding * 2 + margin + infoRect.height)) /
          2
        window.scrollBy(0, rect.top - newTop)
      } else {
        // 恕我无能为力
        // 回到默认位置
      }
      left = adjustLeft()
      top = rect.bottom + padding + margin + windowPageYOffset
    }
    return {
      left,
      top
    }
  }
}
// css.ts
import { prefix } from './utils'

let styleEl: HTMLStyleElement = null

export const addCss = () => {
  let cssText = ''
  // 高亮元素样式
  cssText += `
        .${prefix}highlight-el {
            position: absolute;
        }
    `
  // 信息元素样式
  cssText += `
        .${prefix}info-el {
            position: absolute;
            min-width: 250px;
            max-width: 300px;
        }

        .${prefix}info-el-header {
            display: flex;
            align-items: center;
            justify-content: space-between;
        }

        .${prefix}info-el-title {
            font-size: 18px;
            margin: 0;
            padding: 0;
            font-weight: 700;
        }

        .${prefix}info-el-close {
            cursor: pointer;
            color: #616161;
            font-size: 22px;
            font-weight: 700;
        }

        .${prefix}info-el-info {
            padding: 15px 0;
        }

        .${prefix}info-el-info-img {
            width: 100%;
        }

        .${prefix}info-el-info-text {

        }

        .${prefix}info-el-indicator {
            display: flex;
            align-items: center;
            justify-content: center;
            margin-bottom: 10px;
        }

        .${prefix}info-el-indicator-item {
            width: 6px;
            height: 6px;
            background: #ccc;
            transition: width .1s ease-in;
            border-radius: 10px;
            cursor: pointer;
            margin: 0 2px;
        }

        .${prefix}info-el-indicator-item.active, .${prefix}info-el-indicator-item:hover {
            width: 15px;
            background: #999;
        }

        .${prefix}info-el-btn-group {
            display: flex;
            align-items: center;
            justify-content: space-between;
            border-top: 1px solid #e0e0e0;
            padding-top: 10px;
        }

        .${prefix}info-el-btn {
            width: 60px;
            height: 35px;
            display: flex;
            align-items: center;
            justify-content: center;
            border: 1px solid #bdbdbd;
            text-shadow: 1px 1px 0 #fff;
            font-size: 14px;
            color: #424242;
            white-space: nowrap;
            cursor: pointer;
            background-color: #f4f4f4;
            border-radius: 3px;
        }

        .${prefix}info-el-btn.disabled {
            color: #9e9e9e;
            border-color: #bdbdbd;
            cursor: default;
            background-color: #f4f4f4;
        }

        .${prefix}info-el-btn:hover {
            border-color: #9e9e9e;
            background-color: #e0e0e0;
            color: #212121;
        }

        .${prefix}info-el-btn.disabled:hover {
            color: #9e9e9e;
            border-color: #bdbdbd;
            cursor: default;
            background-color: #f4f4f4;
        }
    `
  // 添加到页面
  styleEl = document.createElement('style')
  styleEl.innerHTML = cssText
  document.head.appendChild(styleEl)
}

export const removeCss = () => {
  if (styleEl) {
    document.head.removeChild(styleEl)
  }
}
// utils.ts
export const prefix = 'simple-novice-guide-'

// 获取一个html节点最近的可滚动的祖先节点
export const getScrollAncestor = (el: HTMLElement) => {
  let style = window.getComputedStyle(el)
  const isAbsolute = style.position === 'absolute'
  const isFixed = style.position === 'fixed'
  const reg = /(auto|scroll)/
  // 如果元素是固定定位,那么可滚动祖先元素为body
  if (isFixed) return document.body
  let parent = el.parentElement
  while (parent) {
    style = window.getComputedStyle(parent)
    // 如果是绝对定位,那么可滚动的祖先元素必须是有定位的才行
    if (!(isAbsolute && style.position === 'static')) {
      // 如果某个祖先元素的overflow属性为auto或scroll则代表是可滚动的
      if (reg.test(style.overflow + style.overflowX + style.overflowY)) {
        return parent
      }
    }
    parent = parent.parentElement
  }
  return document.body
}

// 滚动一个节点的最近一个可滚动的祖先节点,让节点出现在祖先节点的可视区域内
export const scrollAncestorToElement = (el: HTMLElement) => {
  const parent = getScrollAncestor(el)
  if (parent === document.body) return
  let parentRect = parent.getBoundingClientRect()
  let rect = el.getBoundingClientRect()
  parent.scrollTop = parent.scrollTop + rect.top - parentRect.top
  scrollAncestorToElement(parent)
}

// 判断一个节点是否在屏幕可视区域
export const elementIsInView = (el: HTMLElement) => {
  const rect = el.getBoundingClientRect()
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= window.innerHeight &&
    rect.right <= window.innerWidth
  )
}

// 加载图片
export const loadImage = (img: string) => {
  return new Promise((resolve, reject) => {
    let image = new Image()
    image.onload = resolve
    image.onerror = reject
    image.src = img
  })
}
// index.ts
import HighlightElement from './src/HighlightElement'
import InfoElement from './src/InfoElement'
import { scrollAncestorToElement, elementIsInView } from './src/utils'
import { addCss, removeCss } from './src/css'
import EventEmitter from 'eventemitter3'

export interface Step {
  element: HTMLElement
  title: string | number
  text: string | number
  img: string
}

type GetCustomInfoEl = (step: Step) => Promise<HTMLElement>

interface Options {
  padding?: number
  margin?: number
  boxShadowColor?: string
  transition?: string
  borderRadius?: string
  highlightElClass?: string
  backgroundColor?: string
  infoElClass?: string
  prevText?: string
  nextText?: string
  completeText?: string
  showIndicator?: boolean
  zIndex?: number
  useCustomInfo?: boolean
  getCustomInfoEl?: GetCustomInfoEl
  steps: Array<
    Step & {
      element: HTMLElement | string
    }
  >
}

type Steps = Array<Step>

interface DefaultOptions {
  // 高亮元素和信息框元素的内边距
  padding: number
  // 高亮元素和信息框元素之间的间距
  margin: number
  // 高亮元素的box-shadow颜色
  boxShadowColor: string
  // 高亮元素过渡效果
  transition: string
  // 高亮元素和信息框元素的圆角
  borderRadius: string
  // 要添加到高亮元素上的css类名
  highlightElClass: string
  // 信息框元素的背景颜色
  backgroundColor: string
  // 要添加到信息框元素上的css类名
  infoElClass: string
  // 上一步按钮的文字
  prevText: string
  // 下一步按钮的文字
  nextText: string
  // 完成按钮的文字
  completeText: string
  // 是否显示信息框内的指示器
  showIndicator: boolean
  // 高亮元素和信息框的z-index
  zIndex: number
  // 是否使用自定义的信息框,如果开启,需要传递getCustomInfoEl选项
  useCustomInfo: boolean
  // 返回自定义信息框元素
  getCustomInfoEl: GetCustomInfoEl
  // 步骤数据
  steps: Array<Step>
}

// 默认配置
const defaultOptions: DefaultOptions = {
  padding: 10,
  margin: 10,
  boxShadowColor: 'rgba(0, 0, 0, 0.5)',
  transition: 'all 0.3s ease-out',
  borderRadius: '5px',
  highlightElClass: '',
  backgroundColor: '#fff',
  infoElClass: '',
  prevText: '上一步',
  nextText: '下一步',
  completeText: '完成',
  showIndicator: true,
  zIndex: 9999,
  useCustomInfo: false,
  getCustomInfoEl: null,
  steps: []
}

// 入口类
class NoviceGuide extends EventEmitter {
  public steps: Steps
  public currentStepIndex: number
  public infoElement: InfoElement
  public highlightElement: HighlightElement
  public addedCss: boolean
  constructor(public options: Options) {
    super()
    // 选项
    this.options = Object.assign(defaultOptions, options)
    // 步骤数据
    this.steps = []
    // 当前所在步骤
    this.currentStepIndex = -1
    // 实例化辅助类
    this.highlightElement = new HighlightElement(this)
    this.infoElement = new InfoElement(this)
    // 初始化步骤数据
    this.initSteps()
  }

  // 初始化步骤数据
  initSteps() {
    this.options.steps.forEach(step => {
      this.steps.push({
        ...step,
        element:
          typeof step.element === 'string'
            ? document.querySelector(step.element)
            : step.element
      })
    })
  }

  // 开始
  start() {
    if (this.steps.length <= 0) return
    // 添加元素的样式到页面
    if (!this.addedCss) {
      addCss()
      this.addedCss = true
    }
    this.next()
  }

  // 下一步
  next() {
    this.emit('before-step-change', this.currentStepIndex)
    if (this.currentStepIndex + 1 >= this.steps.length) {
      return this.done()
    }
    this.currentStepIndex++
    this.to(this.currentStepIndex)
  }

  // 上一步
  prev() {
    this.emit('before-step-change', this.currentStepIndex)
    if (this.currentStepIndex - 1 < 0) {
      return
    }
    this.currentStepIndex--
    this.to(this.currentStepIndex)
  }

  // 跳转到指定步骤
  jump(stepIndex: number) {
    this.currentStepIndex = stepIndex
    this.to(stepIndex)
  }

  // 达到某一步
  async to(stepIndex: number) {
    const currentStep = this.steps[stepIndex]
    // 当前步骤没有元素就不用处理滚动
    if (currentStep.element) {
      scrollAncestorToElement(currentStep.element)
      const rect = currentStep.element.getBoundingClientRect()
      const windowHeight = window.innerHeight
      if (!elementIsInView(currentStep.element)) {
        window.scrollBy(0, rect.top - (windowHeight - rect.height) / 2)
      }
    }
    this.highlightElement.show(currentStep)
    await this.infoElement.show(currentStep)
    this.emit('after-step-change', stepIndex)
  }

  // 结束
  done() {
    this.emit('before-step-change', this.currentStepIndex)
    this.highlightElement.removeEl()
    this.infoElement.removeEl()
    removeCss()
    this.addedCss = false
    this.currentStepIndex = -1
    this.emit('after-step-change', this.currentStepIndex)
    this.emit('done')
  }

  // 是否是第一步
  isFirstStep() {
    return this.currentStepIndex <= 0
  }

  // 是否是最后一步
  isLastStep() {
    return this.currentStepIndex >= this.steps.length - 1
  }
}

export default NoviceGuide
贡献者: mankueng