|
|
import { navigating } from "$app/state"; |
|
|
import { tick } from "svelte"; |
|
|
|
|
|
|
|
|
const BOTTOM_THRESHOLD = 50; |
|
|
const USER_SCROLL_DEBOUNCE_MS = 150; |
|
|
const PROGRAMMATIC_SCROLL_GRACE_MS = 100; |
|
|
const TOUCH_DETACH_THRESHOLD_PX = 10; |
|
|
|
|
|
interface ScrollDependency { |
|
|
signal: unknown; |
|
|
forceReattach?: number; |
|
|
} |
|
|
|
|
|
type MaybeScrollDependency = ScrollDependency | unknown; |
|
|
|
|
|
const getForceReattach = (value: MaybeScrollDependency): number => { |
|
|
if (typeof value === "object" && value !== null && "forceReattach" in value) { |
|
|
return (value as ScrollDependency).forceReattach ?? 0; |
|
|
} |
|
|
return 0; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const snapScrollToBottom = (node: HTMLElement, dependency: MaybeScrollDependency) => { |
|
|
|
|
|
|
|
|
|
|
|
let isDetached = false; |
|
|
|
|
|
|
|
|
let lastForceReattach = getForceReattach(dependency); |
|
|
|
|
|
|
|
|
let userScrolling = false; |
|
|
let userScrollTimeout: ReturnType<typeof setTimeout> | undefined; |
|
|
|
|
|
|
|
|
let isProgrammaticScroll = false; |
|
|
let lastProgrammaticScrollTime = 0; |
|
|
|
|
|
|
|
|
let prevScrollTop = node.scrollTop; |
|
|
|
|
|
|
|
|
let touchStartY = 0; |
|
|
|
|
|
|
|
|
let resizeObserver: ResizeObserver | undefined; |
|
|
let intersectionObserver: IntersectionObserver | undefined; |
|
|
let sentinel: HTMLDivElement | undefined; |
|
|
|
|
|
|
|
|
let lastScrollHeight = node.scrollHeight; |
|
|
|
|
|
|
|
|
|
|
|
const clearUserScrollTimeout = () => { |
|
|
if (userScrollTimeout) { |
|
|
clearTimeout(userScrollTimeout); |
|
|
userScrollTimeout = undefined; |
|
|
} |
|
|
}; |
|
|
|
|
|
const distanceFromBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight; |
|
|
|
|
|
const isAtBottom = () => distanceFromBottom() <= BOTTOM_THRESHOLD; |
|
|
|
|
|
const scrollToBottom = () => { |
|
|
isProgrammaticScroll = true; |
|
|
lastProgrammaticScrollTime = Date.now(); |
|
|
|
|
|
node.scrollTo({ top: node.scrollHeight }); |
|
|
|
|
|
if (typeof requestAnimationFrame === "function") { |
|
|
requestAnimationFrame(() => { |
|
|
isProgrammaticScroll = false; |
|
|
}); |
|
|
} else { |
|
|
isProgrammaticScroll = false; |
|
|
} |
|
|
}; |
|
|
|
|
|
const settleScrollAfterLayout = async () => { |
|
|
if (typeof requestAnimationFrame !== "function") return; |
|
|
|
|
|
const raf = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); |
|
|
|
|
|
await raf(); |
|
|
if (!userScrolling && !isDetached) { |
|
|
scrollToBottom(); |
|
|
} |
|
|
|
|
|
await raf(); |
|
|
if (!userScrolling && !isDetached) { |
|
|
scrollToBottom(); |
|
|
} |
|
|
}; |
|
|
|
|
|
const scheduleUserScrollEndCheck = () => { |
|
|
userScrolling = true; |
|
|
clearUserScrollTimeout(); |
|
|
|
|
|
userScrollTimeout = setTimeout(() => { |
|
|
userScrolling = false; |
|
|
|
|
|
|
|
|
if (isAtBottom()) { |
|
|
isDetached = false; |
|
|
} |
|
|
|
|
|
|
|
|
if (!isDetached) { |
|
|
scrollToBottom(); |
|
|
} |
|
|
}, USER_SCROLL_DEBOUNCE_MS); |
|
|
}; |
|
|
|
|
|
const createSentinel = () => { |
|
|
sentinel = document.createElement("div"); |
|
|
sentinel.style.height = "1px"; |
|
|
sentinel.style.width = "100%"; |
|
|
sentinel.setAttribute("aria-hidden", "true"); |
|
|
sentinel.setAttribute("data-scroll-sentinel", ""); |
|
|
|
|
|
|
|
|
const container = node.firstElementChild; |
|
|
if (container) { |
|
|
container.appendChild(sentinel); |
|
|
} else { |
|
|
node.appendChild(sentinel); |
|
|
} |
|
|
}; |
|
|
|
|
|
const setupIntersectionObserver = () => { |
|
|
if (typeof IntersectionObserver === "undefined" || !sentinel) return; |
|
|
|
|
|
intersectionObserver = new IntersectionObserver( |
|
|
(entries) => { |
|
|
const entry = entries[0]; |
|
|
|
|
|
|
|
|
if (entry?.isIntersecting && !userScrolling) { |
|
|
isDetached = false; |
|
|
|
|
|
scrollToBottom(); |
|
|
} |
|
|
}, |
|
|
{ |
|
|
root: node, |
|
|
threshold: 0, |
|
|
rootMargin: `0px 0px ${BOTTOM_THRESHOLD}px 0px`, |
|
|
} |
|
|
); |
|
|
|
|
|
intersectionObserver.observe(sentinel); |
|
|
}; |
|
|
|
|
|
const setupResizeObserver = () => { |
|
|
if (typeof ResizeObserver === "undefined") return; |
|
|
|
|
|
const target = node.firstElementChild ?? node; |
|
|
resizeObserver = new ResizeObserver(() => { |
|
|
|
|
|
if (isDetached && !navigating.to) return; |
|
|
|
|
|
if (userScrolling) return; |
|
|
|
|
|
scrollToBottom(); |
|
|
}); |
|
|
|
|
|
resizeObserver.observe(target); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const handleForceReattach = async (newDependency: MaybeScrollDependency) => { |
|
|
const forceReattach = getForceReattach(newDependency); |
|
|
|
|
|
if (forceReattach > lastForceReattach) { |
|
|
lastForceReattach = forceReattach; |
|
|
isDetached = false; |
|
|
userScrolling = false; |
|
|
clearUserScrollTimeout(); |
|
|
|
|
|
await tick(); |
|
|
scrollToBottom(); |
|
|
return true; |
|
|
} |
|
|
|
|
|
return false; |
|
|
}; |
|
|
|
|
|
async function updateScroll(newDependency?: MaybeScrollDependency) { |
|
|
|
|
|
if (newDependency && (await handleForceReattach(newDependency))) { |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (isDetached && !navigating.to) return; |
|
|
|
|
|
|
|
|
if (userScrolling) return; |
|
|
|
|
|
|
|
|
const currentHeight = node.scrollHeight; |
|
|
if (isAtBottom() && currentHeight === lastScrollHeight) { |
|
|
return; |
|
|
} |
|
|
lastScrollHeight = currentHeight; |
|
|
|
|
|
|
|
|
await tick(); |
|
|
scrollToBottom(); |
|
|
await settleScrollAfterLayout(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handleWheel = (event: WheelEvent) => { |
|
|
const { deltaY } = event; |
|
|
|
|
|
|
|
|
if (deltaY < 0) { |
|
|
isDetached = true; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (deltaY > 0 && isAtBottom()) { |
|
|
isDetached = false; |
|
|
userScrolling = false; |
|
|
clearUserScrollTimeout(); |
|
|
scrollToBottom(); |
|
|
return; |
|
|
} |
|
|
|
|
|
scheduleUserScrollEndCheck(); |
|
|
}; |
|
|
|
|
|
|
|
|
const handleTouchStart = (event: TouchEvent) => { |
|
|
touchStartY = event.touches[0]?.clientY ?? 0; |
|
|
}; |
|
|
|
|
|
const handleTouchMove = (event: TouchEvent) => { |
|
|
const touchY = event.touches[0]?.clientY ?? 0; |
|
|
const deltaY = touchStartY - touchY; |
|
|
|
|
|
|
|
|
if (deltaY < -TOUCH_DETACH_THRESHOLD_PX) { |
|
|
isDetached = true; |
|
|
} |
|
|
|
|
|
|
|
|
if (deltaY > TOUCH_DETACH_THRESHOLD_PX && isAtBottom()) { |
|
|
isDetached = false; |
|
|
userScrolling = false; |
|
|
clearUserScrollTimeout(); |
|
|
scrollToBottom(); |
|
|
touchStartY = touchY; |
|
|
return; |
|
|
} |
|
|
|
|
|
scheduleUserScrollEndCheck(); |
|
|
touchStartY = touchY; |
|
|
}; |
|
|
|
|
|
|
|
|
const handleScroll = () => { |
|
|
const now = Date.now(); |
|
|
const timeSinceLastProgrammaticScroll = now - lastProgrammaticScrollTime; |
|
|
const inGracePeriod = |
|
|
isProgrammaticScroll || timeSinceLastProgrammaticScroll < PROGRAMMATIC_SCROLL_GRACE_MS; |
|
|
|
|
|
|
|
|
if (!userScrolling) { |
|
|
const scrollingUp = node.scrollTop < prevScrollTop; |
|
|
|
|
|
|
|
|
if (scrollingUp) { |
|
|
isDetached = true; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (!inGracePeriod && isAtBottom()) { |
|
|
isDetached = false; |
|
|
|
|
|
scrollToBottom(); |
|
|
} |
|
|
} |
|
|
|
|
|
prevScrollTop = node.scrollTop; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
node.addEventListener("wheel", handleWheel, { passive: true }); |
|
|
node.addEventListener("touchstart", handleTouchStart, { passive: true }); |
|
|
node.addEventListener("touchmove", handleTouchMove, { passive: true }); |
|
|
node.addEventListener("scroll", handleScroll, { passive: true }); |
|
|
|
|
|
createSentinel(); |
|
|
setupIntersectionObserver(); |
|
|
setupResizeObserver(); |
|
|
|
|
|
|
|
|
if (dependency) { |
|
|
void (async () => { |
|
|
await tick(); |
|
|
scrollToBottom(); |
|
|
})(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return { |
|
|
update: updateScroll, |
|
|
destroy: () => { |
|
|
clearUserScrollTimeout(); |
|
|
|
|
|
node.removeEventListener("wheel", handleWheel); |
|
|
node.removeEventListener("touchstart", handleTouchStart); |
|
|
node.removeEventListener("touchmove", handleTouchMove); |
|
|
node.removeEventListener("scroll", handleScroll); |
|
|
|
|
|
resizeObserver?.disconnect(); |
|
|
intersectionObserver?.disconnect(); |
|
|
sentinel?.remove(); |
|
|
}, |
|
|
}; |
|
|
}; |
|
|
|