dx: better typing for domOn

Massimo Melina committed Jan 5, 2026 at 12:04 UTC ee0cee87e0cacd329721ded617419c864aa18aff
3 files changed +32 -7
frontend/src/show.ts
+1 -1
@@ -116,7 +116,7 @@ export function fileShow(entry: DirEntry, { startPlaying=false, startShuffle=fal
116 }
117 if (showElement instanceof HTMLMediaElement) {
118 showElement.play().catch(playFailed)
119 - return domOn('ended', goNext, { target: showElement as any })
119 + return domOn('ended', goNext, { target: showElement })
120 }
121 if (!autoPlaying) return // we reached here because of the justOpen, but we are not interested in images
122 // we are supposedly showing an image
shared/index.ts
+27 -3
@@ -56,10 +56,33 @@ export function buildUrlQueryString(params: Dict) { // not using URLSearchParams
56 return '?' + Object.entries(params).filter(pair => pair[1] !== undefined).map(pair => pair.map(x => encodeURIComponent(x).replaceAll('%2F','/')).join('=') ).join('&')
57 }
58
59 -export function domOn<K extends keyof WindowEventMap>(eventName: K, cb: (ev: WindowEventMap[K]) => void, { target=window }={}) {
59 +type DomOnEventMap<T> =
60 + T extends Window ? WindowEventMap :
61 + T extends Document ? DocumentEventMap :
62 + T extends HTMLMediaElement ? HTMLMediaElementEventMap :
63 + T extends HTMLElement ? HTMLElementEventMap :
64 + T extends SVGElement ? SVGElementEventMap :
65 + T extends Element ? ElementEventMap :
66 + T extends MediaQueryList ? MediaQueryListEventMap :
67 + T extends EventTarget ? Record<string, Event> :
68 + never
69 +
70 +type DomOnTarget<O> = O extends { target?: infer T } ? T : Window
71 +type DomOnEventMapFor<O> = DomOnEventMap<DomOnTarget<O>>
72 +
73 +// default target is `window`
74 +export function domOn<
75 + O extends boolean | undefined | { target?: EventTarget } & AddEventListenerOptions = undefined,
76 + K extends keyof DomOnEventMapFor<O> & string = keyof DomOnEventMapFor<O> & string
77 +>(
78 + eventName: K,
79 + cb: (ev: DomOnEventMapFor<O>[K]) => void,
80 + options?: O
81 +) {
82 + const target = options && 'target' in options ? options.target : window
83 if (!target) return
61 - target.addEventListener(eventName, cb)
62 - return () => target.removeEventListener(eventName, cb)
84 + target.addEventListener(eventName, cb as EventListener, options)
85 + return () => target.removeEventListener(eventName, cb as EventListener, options)
86 }
87
88 export function restartAnimation(e: HTMLElement | null | undefined, animation: string) {
@@ -182,6 +205,7 @@ export function createDurationFormatter({ locale=undefined, unitDisplay='narrow'
205 }
206
207 export async function copyTextToClipboard(text: string) {
208 + text = String(text)
209 try {
210 await navigator.clipboard.writeText(text) // this method works only in https and localhost
211 }
shared/react.ts
+4 -3
@@ -210,7 +210,7 @@ export function isCtrlKey(ev: KeyboardEvent) {
210 }
211
212 export function useAutoScroll(dependency: any) {
213 - const ref = useRef<any>()
213 + const ref = useRef<HTMLElement | null>(null)
214 const lastScrollListenerRef = useRef<any>()
215 const [goBottom, setGoBottom] = useState(true)
216 useEffect(() => {
@@ -218,12 +218,13 @@ export function useAutoScroll(dependency: any) {
218 if (goBottom)
219 el?.scrollTo(0, el.scrollHeight)
220 }, [goBottom, dependency])
221 - return useCallback((el: any) => {
221 + return useCallback((el: HTMLElement | null) => {
222 ref.current = el
223 // reinstall listener
224 lastScrollListenerRef.current?.()
225 + if (!el) return
226 lastScrollListenerRef.current = domOn('scroll', ev => {
226 - const el = ev.target as HTMLDivElement
227 + const el = ev.target as HTMLElement
228 if (!el) return
229 setGoBottom(el.scrollTop + el.clientHeight >= el.scrollHeight - 3)
230 }, { target: el })