Files
NewBlog/src/scripts/back-to-top.ts
T

75 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { onPage } from "./lifecycle"
/**
* 回到顶部按钮。
*
* 显隐**默认交给 CSS**:`animation-timeline: scroll(root block)` 让按钮随滚动位置
* 淡入,不需要任何 scroll 监听(见 global.css 的 .back-to-top)。
* 这里只负责两件事:
* 1. 点击后平滑回到顶部;
* 2. 兜住「CSS 那条动画没生效」的所有情况 —— 既包括不支持滚动驱动动画的浏览器
* (Firefox < 144 / Safari < 26),也包括规则被构建压缩弄坏的情况(见下)。
*/
/** 与 global.css 里 animation-range 的起点呼应 */
const REVEAL_AFTER_PX = 480
/** global.css 中 @keyframes 的名字,用来实测动画有没有真的挂上 */
const ANIMATION_NAME = "back-to-top-reveal"
/**
* 「CSS 那条滚动驱动动画到底有没有生效」——直接读计算样式,而不是做特性检测。
*
* 为什么不用 CSS.supports("animation-timeline: scroll()"):它只回答「这个语法浏览器认不认」,
* 回答不了「这条规则有没有在构建压缩中活下来」。实际的坑是:Lightning CSS 会把
* animation-timeline 合并进 animation 简写,而 WebKit 不认简写里的 timeline 分量,
* 整条声明被丢弃 —— 此时 CSS.supports 仍然返回 true,于是降级分支永远不执行,
* 按钮就永远停在 opacity:0。改读 animationName 之后,CSS 那边无论因为什么原因没生效,
* 这里都能立刻发现并接管(详见 global.css 里 .back-to-top 的注释)。
*/
const cssDrivesVisibility = (button: HTMLElement) =>
window
.getComputedStyle(button)
.animationName.split(",")
.some((part) => part.trim() === ANIMATION_NAME)
onPage("back-to-top", () => {
const button = document.querySelector<HTMLElement>("[data-back-to-top]")
if (!button) return
const onClick = () => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" })
}
button.addEventListener("click", onClick)
if (cssDrivesVisibility(button)) {
return () => button.removeEventListener("click", onClick)
}
// ——— 降级路径 ———
// rAF 节流,且只在跨越阈值时才写 class:滚动本身不产生布局读取(只用 scrollY)
const root = document.documentElement
root.classList.add("no-scroll-timeline")
let frame = 0
const update = () => {
frame = 0
button.classList.toggle("is-visible", window.scrollY > REVEAL_AFTER_PX)
}
const onScroll = () => {
if (frame) return
frame = window.requestAnimationFrame(update)
}
window.addEventListener("scroll", onScroll, { passive: true })
update()
return () => {
window.removeEventListener("scroll", onScroll)
if (frame) window.cancelAnimationFrame(frame)
button.removeEventListener("click", onClick)
root.classList.remove("no-scroll-timeline")
}
})