* feat: card alignment + fix modals * feat: change admon title in restore alert modal * fix: lint * feat: backups queue api into api-client * feat: impl backup queue api endpoints into frontend * feat: ack fix * feat: bulk actions * feat: bulk delete impl * fix: lint * fix: align error states * fix: transition group * feat: ready for qa * fix: lint * feat: qa * feat: stacked admonitions component * fix: issues with stacking * feat: hook up admonition stacking + fix app csp for staging kyros nodes * fix: logs.vue * qa: close stack on admonitions click * fix: all problems with stacked admonitions * qa: admonition cleanup and copy overhaul draft * fix: qa issues padding * fix: padding bug * feat: qa * fix: intercom in app csp bug * fix: positioning intercom * feat: loading overlay on top of console + admon consistency changes * feat: scroll indicator fade in backup delete modal + admon timestamp fix * feat: move action bar behind modal * fix: lint + i18n * fix: server ping spam on filter (cache but clear on unmount) * fix: 1 admon fade in flicker issue * chore: temp staging undo * qa: changes * fix: lint * chore: revert staging to use staging * fix: scoping
182 lines
3.9 KiB
TypeScript
182 lines
3.9 KiB
TypeScript
import { nextTick, onUnmounted, type Ref, ref, watchEffect } from 'vue'
|
|
|
|
import { useDebugLogger } from './debug-logger'
|
|
|
|
export interface ScrollIndicatorOptions {
|
|
watchContent?: boolean
|
|
debounceMs?: number
|
|
tolerance?: number
|
|
debug?: boolean
|
|
}
|
|
|
|
export interface ScrollIndicator {
|
|
showTopFade: Ref<boolean>
|
|
showBottomFade: Ref<boolean>
|
|
checkScrollState: () => void
|
|
forceCheck: () => void
|
|
}
|
|
|
|
export function useScrollIndicator(
|
|
containerRef: Ref<HTMLElement | null>,
|
|
options: ScrollIndicatorOptions = {},
|
|
): ScrollIndicator {
|
|
const { watchContent = true, debounceMs = 0, tolerance = 1, debug = false } = options
|
|
|
|
const showTopFade = ref(false)
|
|
const showBottomFade = ref(false)
|
|
|
|
let resizeObserver: ResizeObserver | null = null
|
|
let mutationObserver: MutationObserver | null = null
|
|
let rafId: number | null = null
|
|
let debounceTimer: number | null = null
|
|
|
|
const log = useDebugLogger('ScrollIndicator')
|
|
|
|
const checkScrollStateInternal = () => {
|
|
const container = containerRef.value
|
|
if (!container) {
|
|
showTopFade.value = false
|
|
showBottomFade.value = false
|
|
if (debug) log('Container not found, hiding fades')
|
|
return
|
|
}
|
|
|
|
if (rafId) {
|
|
cancelAnimationFrame(rafId)
|
|
}
|
|
|
|
rafId = requestAnimationFrame(() => {
|
|
const { scrollTop, scrollHeight, clientHeight } = container
|
|
const isScrollable = scrollHeight > clientHeight + tolerance
|
|
|
|
if (debug) {
|
|
log('Checking scroll state', {
|
|
scrollTop,
|
|
scrollHeight,
|
|
clientHeight,
|
|
isScrollable,
|
|
})
|
|
}
|
|
|
|
if (!isScrollable) {
|
|
showTopFade.value = false
|
|
showBottomFade.value = false
|
|
if (debug) log('Content fits, no fades needed')
|
|
} else {
|
|
showTopFade.value = scrollTop > tolerance
|
|
showBottomFade.value = scrollTop < scrollHeight - clientHeight - tolerance
|
|
|
|
if (debug) {
|
|
log('Fades updated', {
|
|
showTop: showTopFade.value,
|
|
showBottom: showBottomFade.value,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const checkScrollState = () => {
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer)
|
|
}
|
|
|
|
debounceTimer = window.setTimeout(() => {
|
|
checkScrollStateInternal()
|
|
}, debounceMs)
|
|
}
|
|
|
|
const forceCheck = () => {
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer)
|
|
debounceTimer = null
|
|
}
|
|
checkScrollStateInternal()
|
|
}
|
|
|
|
watchEffect((onCleanup) => {
|
|
const container = containerRef.value
|
|
if (!container) {
|
|
if (debug) log('No container, skipping setup')
|
|
return
|
|
}
|
|
|
|
if (debug) log('Setting up observers for container', container)
|
|
|
|
nextTick(() => {
|
|
forceCheck()
|
|
})
|
|
|
|
resizeObserver = new ResizeObserver(() => {
|
|
if (debug) log('ResizeObserver triggered')
|
|
checkScrollState()
|
|
})
|
|
resizeObserver.observe(container)
|
|
|
|
if (watchContent) {
|
|
mutationObserver = new MutationObserver(() => {
|
|
if (debug) log('MutationObserver triggered')
|
|
checkScrollState()
|
|
})
|
|
|
|
mutationObserver.observe(container, {
|
|
childList: true,
|
|
subtree: true,
|
|
characterData: true,
|
|
attributes: false,
|
|
})
|
|
}
|
|
|
|
const handleScroll = () => {
|
|
if (debug) log('Scroll event triggered')
|
|
checkScrollState()
|
|
}
|
|
container.addEventListener('scroll', handleScroll, { passive: true })
|
|
|
|
const handleResize = () => {
|
|
if (debug) log('Window resize triggered')
|
|
checkScrollState()
|
|
}
|
|
window.addEventListener('resize', handleResize, { passive: true })
|
|
|
|
onCleanup(() => {
|
|
if (debug) log('Cleaning up observers and listeners')
|
|
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer)
|
|
debounceTimer = null
|
|
}
|
|
|
|
if (rafId) {
|
|
cancelAnimationFrame(rafId)
|
|
rafId = null
|
|
}
|
|
|
|
resizeObserver?.disconnect()
|
|
resizeObserver = null
|
|
|
|
mutationObserver?.disconnect()
|
|
mutationObserver = null
|
|
|
|
container.removeEventListener('scroll', handleScroll)
|
|
window.removeEventListener('resize', handleResize)
|
|
})
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer)
|
|
}
|
|
if (rafId) {
|
|
cancelAnimationFrame(rafId)
|
|
}
|
|
})
|
|
|
|
return {
|
|
showTopFade,
|
|
showBottomFade,
|
|
checkScrollState,
|
|
forceCheck,
|
|
}
|
|
}
|