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

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

目前有很多帮你实现这种功能的开源库,当然,自己实现一个也不难,而且核心就是一个简单的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
        )
    }
}

高亮元素

目标元素可见了,接下来要做的是高亮它,具体的效果就是页面上只有目标元素是亮的,其他地方都是暗的,这个实现方式我考虑过使用svgcanvas等,比如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)
    }
}

结尾

本文详细的介绍了如何实现一个新手引导的功能,可能还有没有考虑到的问题或者实现上的缺陷,欢迎留言指出。

 完整代码获取:关注公众号「码农园区」,回复 【新手引导】,即可获取

在线示例:wanglin2.github.io/simple-novi…。

附送250套精选项目源码

源码截图

 源码获取:关注公众号「码农园区」,回复 【源码】,即可获取全套源码下载链接

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/719215.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

齐普夫定律在循环神经网络中的语言模型的应用

目录 齐普夫定律解释公式解释图与公式的关系代码与图的分析结论 使用对数表达方式的原因1. 线性化非线性关系2. 方便数据可视化和分析3. 降低数值范围4. 方便参数估计公式详细解释结论 来自&#xff1a;https://zh-v2.d2l.ai/chapter_recurrent-neural-networks/language-model…

智慧校园发展趋势:2024年及未来教育科技展望

展望2024年及未来的教育科技领域&#xff0c;智慧校园的发展正引领着一场教育模式的深刻变革&#xff0c;其核心在于更深层次地融合技术与教育实践。随着人工智能技术的不断成熟&#xff0c;个性化学习将不再停留于表面&#xff0c;而是深入到每个学生的个性化需求之中。通过精…

电感的本质是什么

什么是电感&#xff1f; 电感器件一般是指螺线圈&#xff0c;由导线圈一圈靠一圈地绕在绝缘管上&#xff0c;绝缘管可以是空心的&#xff0c;也可以包含铁芯或磁粉芯。 为什么把’线’绕成’圈’就是电感&#xff1f; 电感的工作原理非常抽象&#xff0c;为了解释什么是电感…

单片机 PWM输入捕获【学习记录】

前言 学习是永无止境的&#xff0c;就算之前学过的东西再次学习一遍也能狗学习到很多东西&#xff0c;输入捕获很早之前就用过了&#xff0c;但是仅仅是照搬例程没有去进行理解。温故而知新&#xff01; 定时器 定时器简介 定时器的分类 高级定时器 通用定时器 基本定时器…

Facebook与地方文化:数字平台的多元表达

在当今数字化时代&#xff0c;社交媒体不仅仅是人们交流的工具&#xff0c;更是促进地方文化传播和表达的重要平台。作为全球最大的社交网络之一&#xff0c;Facebook在连接世界各地用户的同时&#xff0c;也成为了地方文化多元表达的重要舞台。本文将深入探讨Facebook如何通过…

LabVIEW的热门应用

LabVIEW是一种图形化编程语言&#xff0c;因其易用性和强大的功能&#xff0c;在多个行业和领域中广泛应用。介绍LabVIEW在以下五个热门应用领域中的使用情况&#xff0c;&#xff1a;工业自动化、医疗设备与生物医学工程、科学研究与实验室自动化、能源管理与智能电网、航空航…

streamlit markdown里支持latex公式显示

参考&#xff1a; https://docs.streamlit.io/develop/api-reference/write-magic/st.write https://discuss.streamlit.io/t/streamlit-markdown-a-streaming-markdown-component-with-latex-mermaid-table-code-support/72187 也有独立支持的st.latex 接口单独显示公司&…

LeetCode347:前K个高频元素

题目描述 给你一个整数数组 nums 和一个整数 k &#xff0c;请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。 解题思想 使用优先队列 priority_queue<Type, Container, Functional> Type 就是数据类型&#xff0c;Container 就是容器类型&#xff08;C…

Next14的appRouter模式中使用状态管理React-Redux

安装依赖 npm install reduxjs/toolkit react-redux创建store模块 创建 app/store/counterSlice.js文件 "use client"// redux需要作为客户端渲染的模块import { createSlice } from "reduxjs/toolkit"export const counterSlice createSlice({name: &…

解读自然语言处理:技术、应用与未来展望

引言 自然语言处理&#xff08;Natural Language Processing&#xff0c;简称NLP&#xff09;是计算机科学、人工智能和语言学的一个跨学科领域&#xff0c;致力于实现人与计算机之间通过自然语言进行有效沟通的能力。NLP 的核心任务是理解、解释和生成人类语言&#xff0c;使计…

AI早班2024.6.18

先一步知道AI未来&#xff01; 全球AI新闻速递 1.绿米 AI 智能存在传感器 FP1E开售。 2.摩尔线程 师者AI&#xff1a;完成70亿参数教育AI大模型训练测试。 3.Google 在 AI 功能推出新功能&#xff0c;需要明确说明可能出错的地方。 4.北大、快手攻克复杂视频生成难题&#…

生成式人工智能如何改变客户服务

生成式人工智能不仅重新定义了品牌与客户的互动方式&#xff0c;还重新定义了品牌如何优化内部资源&#xff0c;以提供更加个性化和高效的服务。 了解在就业和效率方面的挑战和机遇&#xff0c;使用生成式人工智能工具进行客户服务和支持任务。 生成式人工智能不仅重新定义了品…

一键复制备份轻松守护数据安全;安全删除目标文件夹里的原文件,释放存储空间

在这个信息爆炸的时代&#xff0c;我们的生活中充满了各种各样的数据和信息。从工作文档到家庭照片&#xff0c;从学习资料到个人收藏&#xff0c;这些资料都是我们的宝贵财富。然而&#xff0c;如何高效、安全地管理这些资料&#xff0c;却成为了许多人头疼的问题。今天&#…

关于在word中使用Axmath的报错的解决

介绍 Axmath是数学公式编辑器软件。官网如下。 AxMath/AxGlyph/AxCells (amyxun.com) 支持正版。 在word中使用Axmath 点击word中的“文件”→“选项”。 选择“加载项” 选择“word加载项” 在Axmath默认的安装目录如下&#xff1a; C:\Program Files (x86)\AxMathhao&am…

CSS-0_2 CSS和继承(inherit initial)

文章目录 CSS的层叠和继承inheritinitial很多你以为的样式初始值&#xff0c;其实是用户代理样式 碎碎念 CSS的层叠和继承 在上一篇 CSS和层叠、样式优先级 里已经讲过了层叠和优先级之间的关系&#xff0c;但是在CSS中的层叠除了体现在争抢露脸机会的优先级之外&#xff0c;还…

Pyqt QCustomPlot 简介、安装与实用代码示例(三)

目录 前言实用代码示例Line Style DemoDate Axis DemoParametric Curves DemoBar Chart DemoStatistical Box Demo 所有文章除特别声明外&#xff0c;均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 nixgnauhcuy’s blog&#xff01; 如需转载&#xff0c;请标明出处&#x…

【一文开启StableDiffusion】最火AIGC绘画工具SD阿里云部署指南(含踩坑经验)

Midjonery使用简单&#xff0c;效果出色&#xff0c;不过需要付费。本文将介绍完全开源的另一款产品StableDiffusion&#xff0c;它的社区目前非常活跃&#xff0c;各种插件和微调模型都非常多&#xff0c;而且它无需付费注册&#xff0c;没有速度、网络限制&#xff0c;非常推…

广州巨控科技GRM230系列无线模块:环保监控的新利器*

​近日&#xff0c;广州巨控科技推出了一款功能强大的无线模块——GRM230系列&#xff0c;其独特的IO输入输出与485通讯功能&#xff0c;在环保、河道水质检测、流量液位无线4G传输等方面展现出显著的应用优势&#xff0c;尤其在远程泵站启停与监控领域取得了显著成效。 一、G…

有趣且重要的JS知识合集(22)树相关的算法

0、举例&#xff1a;树形结构原始数据 1、序列化树形结构 /*** 平铺序列化树形结构* param tree 树形结构* param result 转化后一维数组* returns Array<TreeNode>*/ export function flattenTree(tree, result []) {if (tree.length 0) {return result}for (const …

分数计算 中级题目

分数计算 中级题目&#xff1a;多个数参与的计算 参考答案&#xff1a;【仅供参考】CBBCCBCCCC