@rpcbase/client 0.261.0 → 0.263.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useApplyScroll.ts +145 -0
- package/src/hooks/useMediaQuery.ts +31 -0
package/package.json
CHANGED
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react"
|
|
2
|
+
import { useLocation, useNavigate } from "@rpcbase/router"
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
function throttle(callback: (...args: any[]) => void, limit: number) {
|
|
6
|
+
let wait = false
|
|
7
|
+
return (...args: any[]) => {
|
|
8
|
+
if (!wait) {
|
|
9
|
+
callback(...args)
|
|
10
|
+
wait = true
|
|
11
|
+
setTimeout(() => {
|
|
12
|
+
wait = false
|
|
13
|
+
}, limit)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useApplyScroll() {
|
|
19
|
+
const location = useLocation()
|
|
20
|
+
const navigate = useNavigate()
|
|
21
|
+
const locationRef = useRef(location)
|
|
22
|
+
const previousPathRef = useRef(location.pathname)
|
|
23
|
+
|
|
24
|
+
// Keep a ref to the latest location object to use in callbacks without re-binding them
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
locationRef.current = location
|
|
27
|
+
}, [location])
|
|
28
|
+
|
|
29
|
+
const isScrollingProgrammatically = useRef(false)
|
|
30
|
+
const scrollTimeoutRef = useRef<NodeJS.Timeout>(null)
|
|
31
|
+
const isUpdatingHashFromSpy = useRef(false)
|
|
32
|
+
|
|
33
|
+
// Effect for scrolling on path changes or hash changes
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
// If path has changed, scroll to top, or to new hash target
|
|
36
|
+
if (previousPathRef.current !== location.pathname) {
|
|
37
|
+
previousPathRef.current = location.pathname
|
|
38
|
+
if (!location.hash) {
|
|
39
|
+
window.scrollTo({ top: 0, left: 0, behavior: "auto" })
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
if (!location.hash) return
|
|
45
|
+
const id = location.hash.substring(1)
|
|
46
|
+
const el = document.getElementById(id)
|
|
47
|
+
if (el) el.scrollIntoView({ behavior: "smooth" })
|
|
48
|
+
}, 100)
|
|
49
|
+
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Don't scroll if the hash was just updated by our scroll spy
|
|
54
|
+
if (isUpdatingHashFromSpy.current) {
|
|
55
|
+
isUpdatingHashFromSpy.current = false
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (location.hash) {
|
|
60
|
+
const id = location.hash.substring(1)
|
|
61
|
+
const element = document.getElementById(id)
|
|
62
|
+
if (element) {
|
|
63
|
+
isScrollingProgrammatically.current = true
|
|
64
|
+
element.scrollIntoView({ behavior: "smooth" })
|
|
65
|
+
|
|
66
|
+
if (scrollTimeoutRef.current) {
|
|
67
|
+
clearTimeout(scrollTimeoutRef.current)
|
|
68
|
+
}
|
|
69
|
+
scrollTimeoutRef.current = setTimeout(() => {
|
|
70
|
+
isScrollingProgrammatically.current = false
|
|
71
|
+
}, 1000) // Block scroll spy for 1s after programmatic scroll
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, [location.pathname, location.hash])
|
|
75
|
+
|
|
76
|
+
// Effect for the scroll spy
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const handleScroll = throttle(() => {
|
|
79
|
+
if (isScrollingProgrammatically.current) {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sections = Array.from(document.querySelectorAll("section[id]"))
|
|
84
|
+
const scrollPosition = window.scrollY
|
|
85
|
+
const viewportHeight = window.innerHeight
|
|
86
|
+
// Determine the "active" section based on what's in the upper third of the viewport
|
|
87
|
+
const checkPoint = scrollPosition + viewportHeight / 3
|
|
88
|
+
|
|
89
|
+
let activeSectionId: string | null = null
|
|
90
|
+
for (const section of sections) {
|
|
91
|
+
if (section.offsetTop <= checkPoint && section.offsetTop + section.offsetHeight > checkPoint) {
|
|
92
|
+
activeSectionId = section.id
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const newHash = activeSectionId ? `#${activeSectionId}` : ""
|
|
98
|
+
const { pathname, search, hash } = locationRef.current
|
|
99
|
+
|
|
100
|
+
if (hash !== newHash) {
|
|
101
|
+
isUpdatingHashFromSpy.current = true
|
|
102
|
+
// Use replace to not pollute browser history
|
|
103
|
+
navigate(pathname + search + newHash, { replace: true })
|
|
104
|
+
}
|
|
105
|
+
}, 150)
|
|
106
|
+
|
|
107
|
+
document.addEventListener("scroll", handleScroll)
|
|
108
|
+
return () => {
|
|
109
|
+
document.removeEventListener("scroll", handleScroll)
|
|
110
|
+
if (scrollTimeoutRef.current) {
|
|
111
|
+
clearTimeout(scrollTimeoutRef.current)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}, [navigate])
|
|
115
|
+
|
|
116
|
+
// Effect to handle re-clicking on a link that is already in the hash
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const handleClick = (e: MouseEvent) => {
|
|
119
|
+
const target = e.target as HTMLElement
|
|
120
|
+
const link = target.closest("a")
|
|
121
|
+
|
|
122
|
+
if (link && link.hash && link.pathname === location.pathname && link.hash === location.hash) {
|
|
123
|
+
const id = link.hash.substring(1)
|
|
124
|
+
const element = document.getElementById(id)
|
|
125
|
+
if (element) {
|
|
126
|
+
e.preventDefault()
|
|
127
|
+
e.stopPropagation()
|
|
128
|
+
|
|
129
|
+
isScrollingProgrammatically.current = true
|
|
130
|
+
element.scrollIntoView({ behavior: "smooth" })
|
|
131
|
+
|
|
132
|
+
if (scrollTimeoutRef.current) {
|
|
133
|
+
clearTimeout(scrollTimeoutRef.current)
|
|
134
|
+
}
|
|
135
|
+
scrollTimeoutRef.current = setTimeout(() => {
|
|
136
|
+
isScrollingProgrammatically.current = false
|
|
137
|
+
}, 1000)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
document.addEventListener("click", handleClick, true)
|
|
143
|
+
return () => document.removeEventListener("click", handleClick, true)
|
|
144
|
+
}, [location.hash, location.pathname])
|
|
145
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react"
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
const emptyUnsubscribe = () => {}
|
|
5
|
+
|
|
6
|
+
export const useMediaQuery = (query: string): boolean => {
|
|
7
|
+
const isServer = typeof window === "undefined"
|
|
8
|
+
|
|
9
|
+
const subscribe = (callback: () => void) => {
|
|
10
|
+
if (isServer) return emptyUnsubscribe
|
|
11
|
+
|
|
12
|
+
const mql = window.matchMedia(query)
|
|
13
|
+
|
|
14
|
+
// Modern browsers
|
|
15
|
+
if (mql.addEventListener) {
|
|
16
|
+
mql.addEventListener("change", callback)
|
|
17
|
+
return () => mql.removeEventListener("change", callback)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Legacy fallback
|
|
21
|
+
mql.addListener(callback)
|
|
22
|
+
return () => mql.removeListener(callback)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const getSnapshot = () => {
|
|
26
|
+
if (isServer) return false
|
|
27
|
+
return window.matchMedia(query).matches
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => false)
|
|
31
|
+
}
|