bug fixes, and performance improvements, and drawer
This commit is contained in:
@@ -15,6 +15,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
let lastScrollTop = 0;
|
||||
let mutationRaf: number | null = null;
|
||||
let anchorRaf: number | null = null;
|
||||
let anchorDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let observer: MutationObserver | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let lastWidth = 0;
|
||||
@@ -26,15 +27,25 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
const setUnhooked = (value: boolean) => {
|
||||
unhooked.value = value;
|
||||
shouldAutoScroll.value = !value;
|
||||
if (!value) anchor = null;
|
||||
};
|
||||
|
||||
const scrollToBottom = (behavior: ScrollBehavior = 'instant') => {
|
||||
const el = elementRef.value;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bottom = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
if (Math.abs(el.scrollTop - bottom) < 0.5) {
|
||||
setUnhooked(false);
|
||||
lastScrollTop = el.scrollTop;
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammatic = true;
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
top: bottom,
|
||||
behavior,
|
||||
});
|
||||
|
||||
@@ -48,13 +59,19 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
};
|
||||
|
||||
const captureAnchor = (el: HTMLElement) => {
|
||||
if (!unhooked.value) {
|
||||
anchor = null;
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const offsets = [4, 24, 48, 80, 120];
|
||||
|
||||
for (const y of offsets) {
|
||||
const target = document.elementFromPoint(x, rect.top + y);
|
||||
if (!target || target === el || !el.contains(target)) continue;
|
||||
if (!target || target === el || !el.contains(target)) {
|
||||
continue;
|
||||
}
|
||||
anchor = {
|
||||
element: target,
|
||||
offsetFromTop: target.getBoundingClientRect().top - rect.top,
|
||||
@@ -65,8 +82,8 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
anchor = null;
|
||||
};
|
||||
|
||||
const scheduleAnchorCapture = () => {
|
||||
if (anchorRaf !== null) return;
|
||||
const requestAnchorCapture = () => {
|
||||
if (!unhooked.value || anchorRaf !== null) return;
|
||||
anchorRaf = requestAnimationFrame(() => {
|
||||
anchorRaf = null;
|
||||
const el = elementRef.value;
|
||||
@@ -74,13 +91,38 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleAnchorCapture = () => {
|
||||
const el = elementRef.value;
|
||||
// Native scrollend is the primary path; debounce is only a fallback.
|
||||
if (!el || 'onscrollend' in el || !unhooked.value) return;
|
||||
if (anchorDebounceTimeout !== null) {
|
||||
clearTimeout(anchorDebounceTimeout);
|
||||
}
|
||||
anchorDebounceTimeout = setTimeout(() => {
|
||||
anchorDebounceTimeout = null;
|
||||
requestAnchorCapture();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleScrollEnd = () => {
|
||||
if (anchorDebounceTimeout !== null) {
|
||||
clearTimeout(anchorDebounceTimeout);
|
||||
anchorDebounceTimeout = null;
|
||||
}
|
||||
requestAnchorCapture();
|
||||
};
|
||||
|
||||
const restoreAnchor = (el: HTMLElement) => {
|
||||
if (!anchor || !el.contains(anchor.element)) return;
|
||||
if (!anchor || !el.contains(anchor.element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const top = el.getBoundingClientRect().top;
|
||||
const delta =
|
||||
anchor.element.getBoundingClientRect().top - top - anchor.offsetFromTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
if (Math.abs(delta) < 0.5) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammatic = true;
|
||||
el.scrollTop += delta;
|
||||
@@ -99,37 +141,54 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = elementRef.value;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollingUp = el.scrollTop < lastScrollTop;
|
||||
lastScrollTop = el.scrollTop;
|
||||
|
||||
scheduleAnchorCapture();
|
||||
|
||||
if (isProgrammatic) {
|
||||
if (isAtBottom(el)) setUnhooked(false);
|
||||
if (isAtBottom(el)) {
|
||||
setUnhooked(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only re-hook when actually inside the bottom threshold.
|
||||
// Mid-page scroll (up or down) must never re-enable stick.
|
||||
// If the user is scrolling up, they are deliberately navigating away from the bottom.
|
||||
// Unhook immediately so we never fight the scroll or snap back down.
|
||||
if (scrollingUp) {
|
||||
setUnhooked(true);
|
||||
scheduleAnchorCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only re-hook when scrolling down into the bottom threshold.
|
||||
if (isAtBottom(el)) {
|
||||
setUnhooked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollingUp) {
|
||||
setUnhooked(true);
|
||||
}
|
||||
scheduleAnchorCapture();
|
||||
};
|
||||
|
||||
const followIfHooked = () => {
|
||||
if (unhooked.value) return;
|
||||
if (unhooked.value) {
|
||||
return;
|
||||
}
|
||||
scrollToBottom('instant');
|
||||
};
|
||||
|
||||
const handleMutation = () => {
|
||||
if (mutationRaf !== null) return;
|
||||
const handleMutation = (records: MutationRecord[]) => {
|
||||
if (unhooked.value || mutationRaf !== null) {
|
||||
return;
|
||||
}
|
||||
// The isolated sizing mirror changes on every keystroke, but cannot
|
||||
// change scroll height. Keep observing real composer content (files,
|
||||
// tool controls, etc.) as well as message content.
|
||||
if (records.every(record => {
|
||||
const target = record.target instanceof Element ? record.target : record.target.parentElement;
|
||||
return target?.closest('[data-chat-measurement]');
|
||||
})) return;
|
||||
mutationRaf = requestAnimationFrame(() => {
|
||||
mutationRaf = null;
|
||||
followIfHooked();
|
||||
@@ -144,6 +203,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
|
||||
el.addEventListener('wheel', handleWheel, { passive: true });
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
el.addEventListener('scrollend', handleScrollEnd, { passive: true });
|
||||
|
||||
observer = new MutationObserver(handleMutation);
|
||||
observer.observe(el, {
|
||||
@@ -154,7 +214,9 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const current = elementRef.value;
|
||||
if (!current) return;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = current.clientWidth;
|
||||
if (width === lastWidth) {
|
||||
@@ -172,13 +234,18 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!anchor) {
|
||||
captureAnchor(current);
|
||||
}
|
||||
restoreAnchor(current);
|
||||
});
|
||||
resizeObserver.observe(el);
|
||||
|
||||
// Content already taller than the viewport on mount — catch up once
|
||||
requestAnimationFrame(() => {
|
||||
if (!unhooked.value) scrollToBottom('instant');
|
||||
if (!unhooked.value) {
|
||||
scrollToBottom('instant');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -186,6 +253,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
if (el) {
|
||||
el.removeEventListener('wheel', handleWheel);
|
||||
el.removeEventListener('scroll', handleScroll);
|
||||
el.removeEventListener('scrollend', handleScrollEnd);
|
||||
}
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
@@ -199,12 +267,18 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
cancelAnimationFrame(anchorRaf);
|
||||
anchorRaf = null;
|
||||
}
|
||||
if (anchorDebounceTimeout !== null) {
|
||||
clearTimeout(anchorDebounceTimeout);
|
||||
anchorDebounceTimeout = null;
|
||||
}
|
||||
anchor = null;
|
||||
};
|
||||
|
||||
watch(elementRef, (newEl, oldEl) => {
|
||||
detach(oldEl ?? null);
|
||||
if (newEl) attach(newEl);
|
||||
if (newEl) {
|
||||
attach(newEl);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
Reference in New Issue
Block a user