main
ts 239 lines 8.88 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import {
4 createElement as h, Fragment, KeyboardEvent, ReactElement, ReactNode,
5 useCallback, useEffect, useMemo, useRef, useState
6 } from 'react'
7 import { useIsMounted, useWindowSize, useMediaQuery } from 'usehooks-ts'
8 import { Callback, domOn, Falsy, repeat } from '.'
9 import _ from 'lodash'
10
11 export function useStateMounted<T>(init: T) {
12 const isMounted = useIsMounted()
13 const [v, set] = useState(init)
14 const ref = useRef(init)
15 ref.current = v
16 const setIfMounted = useCallback((newValue:T | ((previous:T)=>T)) => {
17 if (isMounted())
18 set(newValue)
19 }, [isMounted, set])
20 return [v, setIfMounted, () => ref.current] as const
21 }
22
23 export function reactFilter(elements: any[]) {
24 return elements.filter(x=> x===0 || x && (!Array.isArray(x) || x.length))
25 }
26
27 export function reactJoin(joiner: string | ReactElement, elements: Parameters<typeof reactFilter>[0]) {
28 const ret = []
29 for (const x of reactFilter(elements))
30 ret.push(x, joiner)
31 ret.splice(-1,1)
32 return dontBotherWithKeys(ret)
33 }
34
35 export function dontBotherWithKeys(elements: ReactNode[]): (ReactNode|string)[] {
36 return elements.map((e,i)=>
37 !e || typeof e === 'string' ? e
38 : Array.isArray(e) ? dontBotherWithKeys(e)
39 : h(Fragment, { key:i, children:e }) )
40 }
41
42 export function useRequestRender() {
43 const [state, setState] = useState(0)
44 return Object.assign(useCallback(() => setState(x => x + 1), [setState]), { state })
45 }
46
47 /* This is very useful when you need to make many requests for the content of a long list,
48 especially if you want to limit such requests to the rendered part, for paginated lists.
49 The requests will be automatically batched while you make calls the simple way.
50 It collects jobs requested by every hook using the same worker, calls the worker once after a delay,
51 and returns each caller only the result matching its own job.
52 Results are cached per worker/job/depend until refresh() schedules that job again, or expireAfter clears it */
53 export function useBatch<Job=unknown,Result=unknown>(
54 worker: Falsy | ((jobs: Job[]) => Promise<Result[]>),
55 job: undefined | Job,
56 { delay=0, expireAfter=0, depend=0 }={}
57 ) {
58 interface Env {
59 batch: Set<Job>
60 cache: Map<Job, Result | null>
61 depend: unknown
62 waiter?: Promise<void>
63 }
64 const worker2env = (useBatch as any).worker2env ||= worker && new Map<typeof worker, Env>()
65 let env = worker2env && worker2env.get(worker)
66 if (env && !_.isEqual(env.depend, depend))
67 env = undefined
68 env ||= worker2env && (() => {
69 // depend scopes the worker cache, so stale results from a previous context won't be reused
70 const ret = { batch: new Set<Job>(), cache: new Map<Job, Result>(), depend } as Env
71 worker2env.set(worker, ret)
72 return ret
73 })()
74 const requestRender = useRequestRender()
75 useEffect(() => {
76 worker && (env.waiter ||= new Promise<void>(resolve => {
77 setTimeout(async () => {
78 try {
79 if (!env.batch.size)
80 return
81 const jobs = [...env.batch.values()]
82 env.batch.clear()
83 worker(jobs).then(res => {
84 jobs.forEach((job, i) =>
85 env.cache.set(job, res[i] ?? null) )
86 }).finally(resolve)
87 if (expireAfter)
88 setTimeout(() => {
89 for (const job of jobs)
90 env.cache.delete(job)
91 }, expireAfter)
92 }
93 finally {
94 env.waiter = undefined
95 }
96 }, delay)
97 })).then(requestRender) // all instances share the same 'waiter', but each instance will call its own 'requestRender'
98 }, [worker, requestRender.state])
99 const cached = env && env.cache.get(job) // don't use ?. as env can be falsy
100 useEffect(() => {
101 if (env && cached === undefined) {
102 requestRender()
103 env.batch.add(job)
104 }
105 }, [env, job, cached])
106 return {
107 data: cached,
108 refresh() {
109 if (!env) return
110 env.batch.add(job)
111 requestRender()
112 }
113 }
114 }
115
116 export function KeepInScreen({ margin, ...props }: any) {
117 const ref = useRef<HTMLDivElement>()
118 const [maxHeight, setMaxHeight] = useState<undefined | number>()
119 const size = useWindowSize()
120 useEffect(() => {
121 const el = ref.current
122 if (!el) return
123 const rect = el.getBoundingClientRect()
124 const doc = document.documentElement
125 const limit = window.innerHeight || doc.clientHeight
126 setMaxHeight(limit - rect?.top - margin)
127 }, [size])
128 return h('div', { ref, style: { maxHeight, overflow: 'auto' }, ...props })
129 }
130
131 export function useIsMobile() {
132 return useMediaQuery('(pointer:coarse)')
133 }
134
135 // workaround for the usability problem caused by sticky headers/footers. Just assign the returned value as ref prop of your sticky element.
136 export function useFixSticky() {
137 return useOnResize(useCallback((_w, h, _el, style) => {
138 Object.assign(document.documentElement.style, {
139 scrollPaddingTop: `${h + (parseFloat(style.top) || 0)}px`,
140 scrollPaddingBottom: `${h + (parseFloat(style.bottom) || 0)}px`,
141 })
142 }, [])).refToPass
143 }
144
145 // returns props to assign to your component, and a copy of the ref; calls back with [width, height]
146 export function useOnResize(cb: (width: number, height: number, target: Element, style: CSSStyleDeclaration) => any) {
147 const ref = useRef<Element | null>(null)
148 const cleanupRef = useRef(_.noop)
149 return useMemo(() => {
150 let lastW = -1
151 let lastH = -1
152
153 function measure(el: Element) {
154 const style = getComputedStyle(el)
155 const w = (el.clientWidth || el.getBoundingClientRect().width)
156 + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
157 + parseFloat(style.borderRightWidth) - parseFloat(style.borderLeftWidth)
158 const h = (el.clientHeight || el.getBoundingClientRect().height)
159 + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
160 + parseFloat(style.borderBottomWidth) - parseFloat(style.borderTopWidth)
161 if (w !== lastW || h !== lastH)
162 cb(lastW = w, lastH = h, el, style)
163 }
164
165 return {
166 ref,
167 refToPass(el: Element | null) {
168 ref.current = el
169 cleanupRef.current()
170 cleanupRef.current = _.noop
171 if (!el) return
172 if (!window.ResizeObserver)
173 return cleanupRef.current = repeat(500, () => measure(el))
174 const ro = new ResizeObserver(_.debounce(entries => measure(entries[0].target), 10))
175 ro.observe(el)
176 cleanupRef.current = () => ro.disconnect()
177 }
178 }
179 }, [cb])
180 }
181
182 export function useGetSize() {
183 const [size, setSize] = useState<[number,number]>()
184 const { refToPass, ref } = useOnResize(useCallback((w, h) => setSize([w, h]), []))
185 return useMemo(() => ({
186 w: size?.[0],
187 h: size?.[1],
188 ref,
189 refToPass
190 }), [size, ref])
191 }
192
193 export function useEffectOnce(cb: Callback, deps: any[]) {
194 const state = useRef<any>()
195 useEffect(() => {
196 if (_.isEqual(deps, state.current)) return
197 state.current = deps
198 cb(...deps)
199 }, deps)
200 }
201
202 export function AriaOnly({ children }: { children?: ReactNode }) {
203 return children ? h('div', { className: 'ariaOnly' }, children) : null
204 }
205
206 export function noAriaTitle(title: string) {
207 return {
208 onMouseEnter(ev: any) {
209 ev.target.title = title
210 }
211 }
212 }
213 export const isMac = navigator.platform.match('Mac')
214 export function isCtrlKey(ev: KeyboardEvent) {
215 return (ev.ctrlKey || isMac && ev.metaKey) && ev.key
216 }
217
218 // returns a callback to be passed as ref of the element
219 export function useAutoScroll(dependency: any) {
220 const ref = useRef<HTMLElement | null>(null)
221 const lastScrollListenerRef = useRef<any>()
222 const [goBottom, setGoBottom] = useState(true)
223 useEffect(() => {
224 const { current: el } = ref
225 if (goBottom)
226 el?.scrollTo(0, el.scrollHeight)
227 }, [goBottom, dependency])
228 return useCallback((el: HTMLElement | null) => {
229 ref.current = el
230 // reinstall listener
231 lastScrollListenerRef.current?.()
232 if (!el) return
233 lastScrollListenerRef.current = domOn('scroll', ev => {
234 const el = ev.target as HTMLElement
235 if (!el) return
236 setGoBottom(el.scrollTop + el.clientHeight >= el.scrollHeight - 3)
237 }, { target: el })
238 }, [])
239 }