fix(app): keep the chat scroller pinned to the end while it resizes (#1860)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:33:06 +02:00
1 parent a2099fb0e2
commit 2af0405317
36 files changed
+169 -189

No files matched your search

@@ -392,7 +392,7 @@ export const MobileSectionNav: React.FC<MobileSectionNavProps> = observer(
const scrollerRef = useRef<ScrollerHandle | null>(null); const scrollerRef = useRef<ScrollerHandle | null>(null);
useEffect(() => { useEffect(() => {
if (!activeSectionId) return; if (!activeSectionId) return;
const node = scrollerRef.current?.getScrollerNode(); const node = scrollerRef.current?.getViewportElement();
if (!node) return; if (!node) return;
const activeButton = node.querySelector(`[data-section-id="${activeSectionId}"]`); const activeButton = node.querySelector(`[data-section-id="${activeSectionId}"]`);
if (activeButton instanceof HTMLElement) { if (activeButton instanceof HTMLElement) {
@@ -125,7 +125,7 @@ export const SettingsModalDesktopScroll: React.FC<SettingsModalDesktopScrollProp
if (!scrollerRef) { if (!scrollerRef) {
return; return;
} }
const node = internalRef.current?.getScrollerNode() ?? null; const node = internalRef.current?.getViewportElement() ?? null;
if (typeof scrollerRef === 'function') { if (typeof scrollerRef === 'function') {
scrollerRef(node); scrollerRef(node);
} else { } else {
@@ -734,7 +734,7 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
const target = event.target as Element | null; const target = event.target as Element | null;
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return; if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return;
if (target instanceof HTMLElement && target.isContentEditable) return; if (target instanceof HTMLElement && target.isContentEditable) return;
const scrollerNode = scrollerRef.current?.getScrollerNode() ?? null; const scrollerNode = scrollerRef.current?.getViewportElement() ?? null;
if (!scrollerNode) return; if (!scrollerNode) return;
if (target && !scrollerNode.contains(target) && document.activeElement !== document.body) return; if (target && !scrollerNode.contains(target) && document.activeElement !== document.body) return;
const sections = Array.from(scrollerNode.querySelectorAll<HTMLElement>('[data-inbox-channel-section]')); const sections = Array.from(scrollerNode.querySelectorAll<HTMLElement>('[data-inbox-channel-section]'));
@@ -69,7 +69,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
scrollerRef.current?.scrollToTop(); scrollerRef.current?.jumpToStartEdge();
}, [location.pathname]); }, [location.pathname]);
const splashScale = useMemo(() => { const splashScale = useMemo(() => {
if (!splashDimensions) return null; if (!splashDimensions) return null;
@@ -242,7 +242,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
() => [channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels], () => [channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels],
[channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels], [channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels],
); );
const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getScrollerNode() ?? null, [scrollerRef]); const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getViewportElement() ?? null, [scrollerRef]);
useDragAutoScroll({active: isDraggingAnything, getScrollElement: getChannelScrollContainer}); useDragAutoScroll({active: isDraggingAnything, getScrollElement: getChannelScrollContainer});
const handleChannelDrop = useCallback( const handleChannelDrop = useCallback(
(item: DragItem, result: DropResult) => { (item: DragItem, result: DropResult) => {
@@ -306,15 +306,15 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
}, [guild.id]); }, [guild.id]);
const handleResize = useCallback((_entry: ResizeObserverEntry, _type: 'container' | 'content') => { const handleResize = useCallback((_entry: ResizeObserverEntry, _type: 'container' | 'content') => {
if (stickToBottomRef.current && scrollerRef.current) { if (stickToBottomRef.current && scrollerRef.current) {
scrollerRef.current.scrollToBottom({animate: false}); scrollerRef.current.jumpToEndEdge({animate: false});
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
const guildDimensions = Dimension.getGuildDimensions(guild.id); const guildDimensions = Dimension.guildDimensionsFor(guild.id);
if (guildDimensions.scrollTo) { if (guildDimensions.scrollTo) {
const element = document.querySelector(`[data-channel-id="${guildDimensions.scrollTo}"]`); const element = document.querySelector(`[data-channel-id="${guildDimensions.scrollTo}"]`);
if (element && scrollerRef.current) { if (element && scrollerRef.current) {
scrollerRef.current.scrollIntoViewNode({node: element as HTMLElement, shouldScrollToStart: false}); scrollerRef.current.revealElement({node: element as HTMLElement, preferStartEdge: false});
} }
DimensionCommands.clearChannelListScrollTo(guild.id); DimensionCommands.clearChannelListScrollTo(guild.id);
} else if (guildDimensions.scrollTop && guildDimensions.scrollTop > 0 && scrollerRef.current) { } else if (guildDimensions.scrollTop && guildDimensions.scrollTop > 0 && scrollerRef.current) {
@@ -337,7 +337,8 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
const unreadCount = ReadStates.getUnreadCount(channelId); const unreadCount = ReadStates.getUnreadCount(channelId);
const mentionCount = ReadStates.getMentionCount(channelId); const mentionCount = ReadStates.getMentionCount(channelId);
const isMuted = const isMuted =
UserGuildSettings.isParentCategoryMuted(guild.id, channelId) || UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId); UserGuildSettings.isParentCategoryMuted(guild.id, channelId) ||
UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId);
const channel = Channels.getChannel(channelId); const channel = Channels.getChannel(channelId);
const unreadBadgesLevel = channel const unreadBadgesLevel = channel
? UserGuildSettings.resolvedUnreadBadgesLevel({ ? UserGuildSettings.resolvedUnreadBadgesLevel({
@@ -360,7 +361,9 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
for (const group of channelGroups) { for (const group of channelGroups) {
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false; const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
const isNullSpace = !group.category; const isNullSpace = !group.category;
const isCategoryMuted = group.category ? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id) : false; const isCategoryMuted = group.category
? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id)
: false;
let filteredTextChannels: Array<Channel>; let filteredTextChannels: Array<Channel>;
let filteredVoiceChannels: Array<Channel>; let filteredVoiceChannels: Array<Channel>;
if (hideMutedChannels) { if (hideMutedChannels) {
@@ -651,7 +651,7 @@ export const FavoritesChannelListContent = observer(() => {
const getScrollElement = useCallback(() => { const getScrollElement = useCallback(() => {
const scroller = scrollerRef.current; const scroller = scrollerRef.current;
if (scroller == null) return null; if (scroller == null) return null;
return scroller.getScrollerNode(); return scroller.getViewportElement();
}, []); }, []);
useDragAutoScroll({active: isDraggingFavorite, getScrollElement}); useDragAutoScroll({active: isDraggingFavorite, getScrollElement});
const favorites = Favorites.sortedChannels; const favorites = Favorites.sortedChannels;
@@ -164,7 +164,7 @@ function resolveScrollerNode(scroller: ScrollerHandle | null): HTMLElement | nul
if (scroller == null) { if (scroller == null) {
return null; return null;
} }
return scroller.getScrollerNode(); return scroller.getViewportElement();
} }
function resolveOwnerWindow(container: HTMLElement): Window & typeof globalThis { function resolveOwnerWindow(container: HTMLElement): Window & typeof globalThis {
@@ -221,7 +221,7 @@ function cancelOwnedSettleTimer(timer: OwnedSettleTimer | null): void {
} }
function alignMountedTarget({animate, direction, scroller, targetNode}: AlignMountedTargetRequest): boolean { function alignMountedTarget({animate, direction, scroller, targetNode}: AlignMountedTargetRequest): boolean {
const container = scroller.getScrollerNode(); const container = scroller.getViewportElement();
if (container == null) return false; if (container == null) return false;
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const targetRect = targetNode.getBoundingClientRect(); const targetRect = targetNode.getBoundingClientRect();
@@ -451,7 +451,7 @@ export const FloatingUnreadIndicators = observer(
if (scrollTarget(edge.id, direction)) { if (scrollTarget(edge.id, direction)) {
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY); scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
if (scroller == null) return; if (scroller == null) return;
const scrollNode = scroller.getScrollerNode(); const scrollNode = scroller.getViewportElement();
if (scrollNode == null) return; if (scrollNode == null) return;
const ownerWindow = resolveOwnerWindow(scrollNode); const ownerWindow = resolveOwnerWindow(scrollNode);
cancelOwnedAnimationFrame(targetAlignmentFrameRef.current); cancelOwnedAnimationFrame(targetAlignmentFrameRef.current);
@@ -467,7 +467,7 @@ export const FloatingUnreadIndicators = observer(
} }
scheduleRefresh(FloatingUnreadRefreshScope.SCROLL); scheduleRefresh(FloatingUnreadRefreshScope.SCROLL);
if (scroller == null) return; if (scroller == null) return;
const scrollNode = scroller.getScrollerNode(); const scrollNode = scroller.getViewportElement();
if (scrollNode == null) return; if (scrollNode == null) return;
const ownerWindow = resolveOwnerWindow(scrollNode); const ownerWindow = resolveOwnerWindow(scrollNode);
cancelOwnedSettleTimer(settleTimerRef.current); cancelOwnedSettleTimer(settleTimerRef.current);
@@ -184,7 +184,7 @@ function resolveGuildFolderId(folderId: number | null): number {
function resolveGuildListScrollerNode(scroller: ScrollerHandle | null): HTMLElement | null { function resolveGuildListScrollerNode(scroller: ScrollerHandle | null): HTMLElement | null {
if (scroller == null) return null; if (scroller == null) return null;
return scroller.getScrollerNode(); return scroller.getViewportElement();
} }
type GuildNavigationRow = type GuildNavigationRow =
@@ -1583,7 +1583,7 @@ const GuildList = observer(() => {
const visibleDMListRows = useFrameBatchedDMListRows(targetDMListRows); const visibleDMListRows = useFrameBatchedDMListRows(targetDMListRows);
const shouldRenderGuildListItems = hasUnavailableGuilds || organizedItems.length > 0; const shouldRenderGuildListItems = hasUnavailableGuilds || organizedItems.length > 0;
const initialGuildListScrollTop = useMemo(() => { const initialGuildListScrollTop = useMemo(() => {
const liveScrollTop = untracked(() => Dimension.getGuildListDimensions().scrollTop); const liveScrollTop = untracked(() => Dimension.currentGuildListDimensions().scrollTop);
if (liveScrollTop > 0) { if (liveScrollTop > 0) {
return liveScrollTop; return liveScrollTop;
} }
@@ -1859,7 +1859,7 @@ const GuildList = observer(() => {
const node = scrollTargetRegistry.getTargetNode(targetId); const node = scrollTargetRegistry.getTargetNode(targetId);
const scroller = scrollRef.current; const scroller = scrollRef.current;
if (node == null || scroller == null) return false; if (node == null || scroller == null) return false;
scroller.scrollIntoViewNode({ scroller.revealElement({
node, node,
alignment, alignment,
animate: Accessibility.useSmoothScrolling, animate: Accessibility.useSmoothScrolling,
@@ -467,7 +467,7 @@ export const GuildRailSkeleton: React.FC<GuildRailSkeletonProps> = ({
const [mountState] = useState(() => { const [mountState] = useState(() => {
const rememberedLayout = getRememberedSkeletonGuildRailLayout(); const rememberedLayout = getRememberedSkeletonGuildRailLayout();
const remScale = getRemScaleForDocument(document); const remScale = getRemScaleForDocument(document);
const liveScrollTop = Math.max(0, Dimension.getGuildListDimensions().scrollTop); const liveScrollTop = Math.max(0, Dimension.currentGuildListDimensions().scrollTop);
let scrollTop = liveScrollTop; let scrollTop = liveScrollTop;
if (liveScrollTop === 0 && rememberedLayout != null) { if (liveScrollTop === 0 && rememberedLayout != null) {
scrollTop = rememberedLayout.scrollTopPx * remScale; scrollTop = rememberedLayout.scrollTopPx * remScale;
@@ -72,7 +72,7 @@ export function WhatsNewModal({entry}: WhatsNewModalProps) {
ModalCommands.pop(); ModalCommands.pop();
}, [entry.id]); }, [entry.id]);
const updateScrollHintVisibility = useCallback(() => { const updateScrollHintVisibility = useCallback(() => {
const scrollerNode = scrollerRef.current?.getScrollerNode(); const scrollerNode = scrollerRef.current?.getViewportElement();
if (!scrollerNode) { if (!scrollerNode) {
setIsScrollHintVisible(false); setIsScrollHintVisible(false);
return; return;
@@ -89,7 +89,7 @@ export function WhatsNewModal({entry}: WhatsNewModalProps) {
updateScrollHintVisibility(); updateScrollHintVisibility();
}, [updateScrollHintVisibility]); }, [updateScrollHintVisibility]);
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
scrollerRef.current?.scrollToBottom({animate: true}); scrollerRef.current?.jumpToEndEdge({animate: true});
}, []); }, []);
useEffect(() => { useEffect(() => {
updateScrollHintVisibility(); updateScrollHintVisibility();
@@ -265,7 +265,7 @@ function resolveScrollNode(scrollerRef: React.RefObject<ScrollerHandle | null>):
if (scroller == null) { if (scroller == null) {
return null; return null;
} }
return scroller.getScrollerNode(); return scroller.getViewportElement();
} }
interface ResolveNavigationRowNodeQuery { interface ResolveNavigationRowNodeQuery {
@@ -311,7 +311,7 @@ function scrollNavigationRowIntoView({
if (node == null) { if (node == null) {
return false; return false;
} }
scroller.scrollIntoViewNode({node, alignment: resolveNavigationScrollerAlignment(align)}); scroller.revealElement({node, alignment: resolveNavigationScrollerAlignment(align)});
return true; return true;
} }
@@ -308,8 +308,8 @@ export const Autocomplete = observer(
const scrollChildIntoView = useCallback((node: HTMLElement | null, margin = 32) => { const scrollChildIntoView = useCallback((node: HTMLElement | null, margin = 32) => {
if (!node) return; if (!node) return;
const scroller = scrollerRef.current as ScrollerWithScrollableElement | null; const scroller = scrollerRef.current as ScrollerWithScrollableElement | null;
if (scroller && typeof scroller.scrollIntoViewNode === 'function') { if (scroller && typeof scroller.revealElement === 'function') {
scroller.scrollIntoViewNode({node, padding: margin}); scroller.revealElement({node, padding: margin});
return; return;
} }
let scrollerEl: HTMLElement | null = null; let scrollerEl: HTMLElement | null = null;
@@ -64,9 +64,9 @@ export const AutocompleteGif = observer(
if (!scrollerRef.current) { if (!scrollerRef.current) {
return; return;
} }
scrollerRef.current.scrollIntoViewNode({ scrollerRef.current.revealElement({
node: selectedElement, node: selectedElement,
shouldScrollToStart: false, preferStartEdge: false,
padding: 0, padding: 0,
}); });
}); });
@@ -597,7 +597,7 @@ export const ChannelAttachmentArea = observer(({channelId}: {channelId: string})
const getScrollElement = useCallback(() => { const getScrollElement = useCallback(() => {
const scroller = scrollerRef.current; const scroller = scrollerRef.current;
if (scroller == null) return null; if (scroller == null) return null;
return scroller.getScrollerNode(); return scroller.getViewportElement();
}, []); }, []);
useDragAutoScroll({active: isDragging, axis: 'horizontal', getScrollElement}); useDragAutoScroll({active: isDragging, axis: 'horizontal', getScrollElement});
channelIdRef.current = channelId; channelIdRef.current = channelId;
@@ -374,7 +374,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
[flushScrollRangeUpdate], [flushScrollRangeUpdate],
); );
const scheduleRangeUpdateFromScroller = useCallback(() => { const scheduleRangeUpdateFromScroller = useCallback(() => {
const scrollerState = scrollerRef.current?.getScrollerState(); const scrollerState = scrollerRef.current?.readViewportMetrics();
if (!scrollerState) { if (!scrollerState) {
return; return;
} }
@@ -254,7 +254,7 @@ export const Messages = observer(function Messages({
}, [channel.id, state]); }, [channel.id, state]);
const onMessageEdit = useCallback( const onMessageEdit = useCallback(
(targetNode: HTMLElement) => { (targetNode: HTMLElement) => {
const scrollerNode = scrollManager.ref.current?.getScrollerNode(); const scrollerNode = scrollManager.ref.current?.getViewportElement();
if (!scrollerNode) return; if (!scrollerNode) return;
if (scrollManager.pinIsAtBottomNow()) { if (scrollManager.pinIsAtBottomNow()) {
return; return;
@@ -272,7 +272,7 @@ export const Messages = observer(function Messages({
const isAbove = targetRect.top < scrollerRect.top; const isAbove = targetRect.top < scrollerRect.top;
const isBelow = targetRect.bottom > scrollerRect.bottom; const isBelow = targetRect.bottom > scrollerRect.bottom;
if (isAbove || isBelow) { if (isAbove || isBelow) {
scrollManager.ref.current?.scrollIntoViewNode({ scrollManager.ref.current?.revealElement({
node: targetNode, node: targetNode,
padding: 80, padding: 80,
animate: false, animate: false,
@@ -369,7 +369,7 @@ export const Messages = observer(function Messages({
const onFocusBottommostMessage = (payload?: unknown) => { const onFocusBottommostMessage = (payload?: unknown) => {
const data = (payload ?? {}) as {channelId?: string}; const data = (payload ?? {}) as {channelId?: string};
if (!data.channelId || data.channelId !== channel.id) return; if (!data.channelId || data.channelId !== channel.id) return;
const scroller = scrollManager.ref.current?.getScrollerNode(); const scroller = scrollManager.ref.current?.getViewportElement();
const scrollerInner = scrollerInnerRef.current; const scrollerInner = scrollerInnerRef.current;
if (!scroller || !scrollerInner) return; if (!scroller || !scrollerInner) return;
const messageElements = scrollerInner.querySelectorAll<HTMLElement>('[data-message-id]'); const messageElements = scrollerInner.querySelectorAll<HTMLElement>('[data-message-id]');
@@ -206,7 +206,7 @@ export const ChannelSearchResults = observer(
setSortMode(cachedSortMode); setSortMode(cachedSortMode);
}, [contextId, searchContext?.lastSearchSortMode]); }, [contextId, searchContext?.lastSearchSortMode]);
const getScrollPosition = useCallback((): number => { const getScrollPosition = useCallback((): number => {
const node = scrollerRef.current?.getScrollerNode(); const node = scrollerRef.current?.getViewportElement();
return node ? node.scrollTop : 0; return node ? node.scrollTop : 0;
}, []); }, []);
const updateScrollPosition = useCallback( const updateScrollPosition = useCallback(
@@ -827,7 +827,7 @@ export const ChannelSearchResults = observer(
clearChannelSearchHighlight(); clearChannelSearchHighlight();
return; return;
} }
const scrollerNode = scrollerRef.current?.getScrollerNode(); const scrollerNode = scrollerRef.current?.getViewportElement();
if (!scrollerNode) return; if (!scrollerNode) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
const terms = tokenizeSearchQuery(searchQuery); const terms = tokenizeSearchQuery(searchQuery);
@@ -187,7 +187,7 @@ export const EmojiPicker = observer(
const handleCategoryClick = (category: string) => { const handleCategoryClick = (category: string) => {
const element = categoryRefs.current.get(category); const element = categoryRefs.current.get(category);
if (element) { if (element) {
scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true}); scrollerRef.current?.revealElement({node: element, preferStartEdge: true});
} }
}; };
const handleHover = (emoji: FlatEmoji | null, row?: number, column?: number) => { const handleHover = (emoji: FlatEmoji | null, row?: number, column?: number) => {
@@ -223,7 +223,7 @@ export const MobileEmojiPicker = observer(
const handleCategoryClick = (category: string) => { const handleCategoryClick = (category: string) => {
const element = categoryRefs.current.get(category); const element = categoryRefs.current.get(category);
if (element) { if (element) {
scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true}); scrollerRef.current?.revealElement({node: element, preferStartEdge: true});
} }
}; };
const handleHover = (emoji: FlatEmoji | null) => { const handleHover = (emoji: FlatEmoji | null) => {
@@ -173,7 +173,7 @@ export const MobileStickersPicker = observer(
const handleCategoryClick = (category: string) => { const handleCategoryClick = (category: string) => {
const element = categoryRefs.current.get(category); const element = categoryRefs.current.get(category);
if (element) { if (element) {
scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true}); scrollerRef.current?.revealElement({node: element, preferStartEdge: true});
} }
}; };
const handleHover = (sticker: GuildSticker | null) => { const handleHover = (sticker: GuildSticker | null) => {
@@ -172,7 +172,7 @@ export const StickersPicker = observer(
const handleCategoryClick = (category: string) => { const handleCategoryClick = (category: string) => {
const element = categoryRefs.current.get(category); const element = categoryRefs.current.get(category);
if (element) { if (element) {
scrollerRef.current?.scrollIntoViewNode({node: element, shouldScrollToStart: true}); scrollerRef.current?.revealElement({node: element, preferStartEdge: true});
} }
}; };
const handleHover = (sticker: GuildSticker | null, row?: number, column?: number) => { const handleHover = (sticker: GuildSticker | null, row?: number, column?: number) => {
@@ -367,7 +367,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
clearChannelSearchHighlight(); clearChannelSearchHighlight();
return; return;
} }
const container = scrollerRef.current?.getScrollerNode(); const container = scrollerRef.current?.getViewportElement();
if (!container) { if (!container) {
return; return;
} }
@@ -1,46 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MasonryListComputer} from '@app/features/channel/components/MasonryListComputer';
import {bench, describe} from 'vitest';
const ITEM_COUNT = 500;
const VIEWPORT_WIDTH = 520;
const VIEWPORT_HEIGHT = 420;
const COLUMNS = 3;
const ITEM_GUTTER = 8;
const ITEM_KEYS = Array.from({length: ITEM_COUNT}, (_, index) => `gif-${index}`);
const ITEM_HEIGHTS = Array.from({length: ITEM_COUNT}, (_, index) => 90 + ((index * 37) % 180));
const SCROLL_OFFSETS = Array.from({length: 1_000}, (_, index) => (index * 173) % 30_000);
function configureComputer(computer: MasonryListComputer, version: number): void {
computer.mergeProps({
sections: [ITEM_COUNT],
columns: COLUMNS,
itemGutter: ITEM_GUTTER,
bufferWidth: VIEWPORT_WIDTH,
padding: {left: 12, right: 12, top: 0, bottom: 0},
version,
getItemKey: (_section, itemIndex) => ITEM_KEYS[itemIndex] ?? null,
getItemHeight: (_section, itemIndex) => ITEM_HEIGHTS[itemIndex] ?? 0,
getSectionHeight: () => 0,
});
}
describe('MasonryListComputer benchmarks', () => {
bench('computes full masonry layout for 500 GIFs', () => {
const computer = new MasonryListComputer();
configureComputer(computer, 1);
computer.computeVisibleSections(0, VIEWPORT_HEIGHT);
computer.getState();
});
bench('computes 1k visible windows over a stable GIF layout', () => {
const computer = new MasonryListComputer();
configureComputer(computer, 1);
computer.computeVisibleSections(0, VIEWPORT_HEIGHT);
for (const scrollTop of SCROLL_OFFSETS) {
computer.computeVisibleSections(scrollTop, scrollTop + VIEWPORT_HEIGHT + 480);
}
computer.getState();
});
});
@@ -60,7 +60,7 @@ export function useScrollerViewport(scrollerRef: React.RefObject<ScrollerHandle
[scheduleViewportSize], [scheduleViewportSize],
); );
useEffect(() => { useEffect(() => {
const state = scrollerRef.current?.getScrollerState(); const state = scrollerRef.current?.readViewportMetrics();
if (!state || state.offsetWidth === 0 || state.offsetHeight === 0) return; if (!state || state.offsetWidth === 0 || state.offsetHeight === 0) return;
scheduleViewportSize({width: state.offsetWidth, height: state.offsetHeight}); scheduleViewportSize({width: state.offsetWidth, height: state.offsetHeight});
}, [scrollerRef, scheduleViewportSize]); }, [scrollerRef, scheduleViewportSize]);
@@ -104,7 +104,7 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
const hasMore = guilds.length < Discovery.total; const hasMore = guilds.length < Discovery.total;
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rowCount, count: rowCount,
getScrollElement: () => scrollerRef.current?.getScrollerNode() ?? null, getScrollElement: () => scrollerRef.current?.getViewportElement() ?? null,
estimateSize: () => SKELETON_DISCOVERY_ESTIMATED_ROW_HEIGHT_PX, estimateSize: () => SKELETON_DISCOVERY_ESTIMATED_ROW_HEIGHT_PX,
overscan: OVERSCAN_ROWS, overscan: OVERSCAN_ROWS,
scrollMargin: gridOffsetTop, scrollMargin: gridOffsetTop,
@@ -131,7 +131,7 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
return; return;
} }
setContainerWidth(node.getBoundingClientRect().width); setContainerWidth(node.getBoundingClientRect().width);
setGridOffsetTop(resolveScrollOffsetTop(node, scrollerRef.current?.getScrollerNode() ?? null)); setGridOffsetTop(resolveScrollOffsetTop(node, scrollerRef.current?.getViewportElement() ?? null));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!resultsColumnNode) return; if (!resultsColumnNode) return;
@@ -149,7 +149,7 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
}, [resultsColumnNode, searchActive]); }, [resultsColumnNode, searchActive]);
useEffect(() => { useEffect(() => {
if (!resultsColumnNode) return; if (!resultsColumnNode) return;
setGridOffsetTop(resolveScrollOffsetTop(resultsColumnNode, scrollerRef.current?.getScrollerNode() ?? null)); setGridOffsetTop(resolveScrollOffsetTop(resultsColumnNode, scrollerRef.current?.getViewportElement() ?? null));
}, [resultsColumnNode, searchActive, containerWidth]); }, [resultsColumnNode, searchActive, containerWidth]);
const scrollResultsToTop = useCallback(() => { const scrollResultsToTop = useCallback(() => {
scrollerRef.current?.scrollTo({to: 0}); scrollerRef.current?.scrollTo({to: 0});
@@ -185,7 +185,7 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
if (searchActive || columns <= 0) { if (searchActive || columns <= 0) {
return; return;
} }
const viewportHeightPx = measureSkeletonHeightPx(scrollerRef.current?.getScrollerNode() ?? null); const viewportHeightPx = measureSkeletonHeightPx(scrollerRef.current?.getViewportElement() ?? null);
reportSkeletonDiscoveryGrid(columns, Math.ceil(viewportHeightPx / SKELETON_DISCOVERY_ESTIMATED_ROW_HEIGHT_PX)); reportSkeletonDiscoveryGrid(columns, Math.ceil(viewportHeightPx / SKELETON_DISCOVERY_ESTIMATED_ROW_HEIGHT_PX));
}, `${searchActive}:${columns}:${containerWidth}`); }, `${searchActive}:${columns}:${containerWidth}`);
const virtualRows = virtualizer.getVirtualItems(); const virtualRows = virtualizer.getVirtualItems();
@@ -18,10 +18,10 @@ interface MessageListKeyboardNavigationOptions {
allowWhenInactive?: boolean; allowWhenInactive?: boolean;
} }
const getScrollerNode = (value: ScrollerHandle | HTMLElement | null | undefined): HTMLElement | null => { const getViewportElement = (value: ScrollerHandle | HTMLElement | null | undefined): HTMLElement | null => {
if (!value) return null; if (!value) return null;
if ('getScrollerNode' in value && typeof value.getScrollerNode === 'function') { if ('getViewportElement' in value && typeof value.getViewportElement === 'function') {
return value.getScrollerNode(); return value.getViewportElement();
} }
if (value instanceof HTMLElement) { if (value instanceof HTMLElement) {
return value; return value;
@@ -109,7 +109,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
const getMessageElementsSnapshot = (): MessageNodesSnapshot => { const getMessageElementsSnapshot = (): MessageNodesSnapshot => {
const now = Date.now(); const now = Date.now();
const cache = messageNodesCache; const cache = messageNodesCache;
const container = getScrollerNode(containerRef?.current ?? null); const container = getViewportElement(containerRef?.current ?? null);
if (!container && containerRef) { if (!container && containerRef) {
messageNodesCache = { messageNodesCache = {
...EMPTY_MESSAGE_NODES_SNAPSHOT, ...EMPTY_MESSAGE_NODES_SNAPSHOT,
@@ -202,7 +202,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
const isNavigationKey = delta !== 0; const isNavigationKey = delta !== 0;
if (isNavigationKey && hasShortcutModifier(event)) return; if (isNavigationKey && hasShortcutModifier(event)) return;
if (!isNavigationKey && event.key !== 'Escape') return; if (!isNavigationKey && event.key !== 'Escape') return;
const container = getScrollerNode(containerRef?.current ?? null); const container = getViewportElement(containerRef?.current ?? null);
if (container && !canHandleInsideContainer(container)) { if (container && !canHandleInsideContainer(container)) {
return; return;
} }
@@ -273,7 +273,7 @@ class Messages {
ChannelMessages.forEach((messages) => channelIds.push(messages.channelId)); ChannelMessages.forEach((messages) => channelIds.push(messages.channelId));
for (const channelId of channelIds) { for (const channelId of channelIds) {
this.clearMessages(channelId); this.clearMessages(channelId);
Dimension.clearChannelDimensions(channelId); Dimension.forgetChannelDimensions(channelId);
} }
this.messageRefsByAuthor.clear(); this.messageRefsByAuthor.clear();
this.indexedAuthorsByChannel.clear(); this.indexedAuthorsByChannel.clear();
@@ -314,7 +314,7 @@ class Messages {
didHydrateSelectedChannel = true; didHydrateSelectedChannel = true;
} else { } else {
this.clearMessages(messages.channelId); this.clearMessages(messages.channelId);
Dimension.clearChannelDimensions(messages.channelId); Dimension.forgetChannelDimensions(messages.channelId);
} }
}); });
if (this.pendingFullHydration && !didHydrateSelectedChannel && selectedChannelId) { if (this.pendingFullHydration && !didHydrateSelectedChannel && selectedChannelId) {
@@ -357,7 +357,7 @@ class Messages {
const messages = ChannelMessages.getOrCreate(channelId); const messages = ChannelMessages.getOrCreate(channelId);
this.commitMessages(messages.mutate({loadingMore: true, ready: false, error: false})); this.commitMessages(messages.mutate({loadingMore: true, ready: false, error: false}));
if (forceScrollToBottom) { if (forceScrollToBottom) {
Dimension.updateChannelDimensions(channelId, 1, 1, 0); Dimension.recordChannelDimensions(channelId, 1, 1, 0);
MessageCommands.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL); MessageCommands.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL);
return; return;
} }
@@ -402,7 +402,7 @@ class Messages {
return false; return false;
} }
if (!GatewayConnection.isConnected || messages.loadingMore || messages.ready) { if (!GatewayConnection.isConnected || messages.loadingMore || messages.ready) {
if (messages.ready && Dimension.isAtBottom(channelId)) { if (messages.ready && Dimension.channelPinnedToEnd(channelId)) {
this.commitMessages(messages.truncateTop(MAX_MESSAGES_PER_CHANNEL)); this.commitMessages(messages.truncateTop(MAX_MESSAGES_PER_CHANNEL));
this.notifyChange(); this.notifyChange();
} }
@@ -450,7 +450,7 @@ class Messages {
const channel = Channels.getChannel(channelId); const channel = Channels.getChannel(channelId);
if (channel && channel.guildId === guildId) { if (channel && channel.guildId === guildId) {
this.clearMessages(channelId); this.clearMessages(channelId);
Dimension.clearChannelDimensions(channelId); Dimension.forgetChannelDimensions(channelId);
didUpdate = true; didUpdate = true;
if (channelId === selectedChannelId) { if (channelId === selectedChannelId) {
selectedChannelAffected = true; selectedChannelAffected = true;
@@ -593,7 +593,7 @@ class Messages {
if (!existing?.ready) { if (!existing?.ready) {
return false; return false;
} }
const updated = existing.receiveMessage(action.message, Dimension.isAtBottom(action.channelId)); const updated = existing.receiveMessage(action.message, Dimension.channelPinnedToEnd(action.channelId));
this.commitMessages(updated); this.commitMessages(updated);
this.notifyChange(); this.notifyChange();
return false; return false;
@@ -725,7 +725,7 @@ class Messages {
ChannelMessages.forEach(({channelId}) => { ChannelMessages.forEach(({channelId}) => {
if (Channels.getChannel(channelId) == null) { if (Channels.getChannel(channelId) == null) {
this.clearMessages(channelId); this.clearMessages(channelId);
Dimension.clearChannelDimensions(channelId); Dimension.forgetChannelDimensions(channelId);
} }
}); });
this.notifyChange(); this.notifyChange();
@@ -48,7 +48,7 @@ class AutoAck {
if (!channelId) { if (!channelId) {
return {windowId, channelId: null, isAtBottom: false, canAutoAck: false}; return {windowId, channelId: null, isAtBottom: false, canAutoAck: false};
} }
const isAtBottom = Dimension.isAtBottom(channelId) ?? false; const isAtBottom = Dimension.channelPinnedToEnd(channelId) ?? false;
const readState = ReadStates.getIfExists(channelId); const readState = ReadStates.getIfExists(channelId);
const ackedManually = readState?.ackedManually ?? false; const ackedManually = readState?.ackedManually ?? false;
const channel = Channels.getChannel(channelId); const channel = Channels.getChannel(channelId);
@@ -94,9 +94,9 @@ export class ScrollManager {
if (props.messages.jumpTargetId != null) { if (props.messages.jumpTargetId != null) {
this.pinIsAtBottom = false; this.pinIsAtBottom = false;
} else { } else {
const stored = Dimension.getChannelDimensions(props.channel.id); const stored = Dimension.channelDimensionsFor(props.channel.id);
this.restoreHadSavedPosition = stored != null; this.restoreHadSavedPosition = stored != null;
const isAtBottom = Dimension.isAtBottom(props.channel.id); const isAtBottom = Dimension.channelPinnedToEnd(props.channel.id);
this.pinIsAtBottom = isAtBottom ?? true; this.pinIsAtBottom = isAtBottom ?? true;
this.restorePendingInitialScrollTop = this.pinIsAtBottom ? null : (stored?.scrollTop ?? null); this.restorePendingInitialScrollTop = this.pinIsAtBottom ? null : (stored?.scrollTop ?? null);
} }
@@ -173,12 +173,12 @@ export class ScrollManager {
} }
scrollGetDocument(): Document | undefined { scrollGetDocument(): Document | undefined {
const node = this.ref.current?.getScrollerNode(); const node = this.ref.current?.getViewportElement();
return node?.ownerDocument; return node?.ownerDocument;
} }
scrollGetState(): ScrollerState { scrollGetState(): ScrollerState {
return this.ref.current?.getScrollerState() ?? DEFAULT_SCROLLER_STATE; return this.ref.current?.readViewportMetrics() ?? DEFAULT_SCROLLER_STATE;
} }
private placeholderHeightGet(): number { private placeholderHeightGet(): number {
@@ -186,7 +186,7 @@ export class ScrollManager {
if (scrollerHandle == null) { if (scrollerHandle == null) {
return this.props.placeholderHeight; return this.props.placeholderHeight;
} }
const scrollerNode = scrollerHandle.getScrollerNode(); const scrollerNode = scrollerHandle.getViewportElement();
if (scrollerNode == null) { if (scrollerNode == null) {
return this.props.placeholderHeight; return this.props.placeholderHeight;
} }
@@ -249,7 +249,7 @@ export class ScrollManager {
callback?: () => void, callback?: () => void,
shouldContinue?: () => boolean, shouldContinue?: () => boolean,
): void { ): void {
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if (!scrollerNode) { if (!scrollerNode) {
callback?.(); callback?.();
return; return;
@@ -263,7 +263,7 @@ export class ScrollManager {
anchorGetData(messageId: string, scrollTop: number, clampTo?: number, layout?: ContainerLayout): AnchorData | null { anchorGetData(messageId: string, scrollTop: number, clampTo?: number, layout?: ContainerLayout): AnchorData | null {
const element = this.layoutGetElementFromMessageId(messageId); const element = this.layoutGetElementFromMessageId(messageId);
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if (!element || !scrollerNode) return null; if (!element || !scrollerNode) return null;
const offsetHeight = element.offsetHeight; const offsetHeight = element.offsetHeight;
const offsetTop = this.layoutGetOffsetTop(element, scrollerNode, layout); const offsetTop = this.layoutGetOffsetTop(element, scrollerNode, layout);
@@ -299,7 +299,7 @@ export class ScrollManager {
const {messages, hasUnreads, channel} = this.props; const {messages, hasUnreads, channel} = this.props;
const state = this.scrollGetState(); const state = this.scrollGetState();
const {scrollTop, offsetHeight} = state; const {scrollTop, offsetHeight} = state;
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined; const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined;
const buffer = const buffer =
hasUnreads && scrollTop >= this.layoutGetNewMessageBarBuffer() ? this.layoutGetNewMessageBarBuffer() : 0; hasUnreads && scrollTop >= this.layoutGetNewMessageBarBuffer() ? this.layoutGetNewMessageBarBuffer() : 0;
@@ -336,7 +336,7 @@ export class ScrollManager {
anchorFindLoadMore(isBefore: boolean): AnchorData | null { anchorFindLoadMore(isBefore: boolean): AnchorData | null {
const {messages} = this.props; const {messages} = this.props;
const {scrollTop} = this.scrollGetState(); const {scrollTop} = this.scrollGetState();
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined; const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined;
const direction = isBefore ? 1 : -1; const direction = isBefore ? 1 : -1;
const startIndex = isBefore ? 0 : messages.length - 1; const startIndex = isBefore ? 0 : messages.length - 1;
@@ -366,7 +366,7 @@ export class ScrollManager {
this.loadIsInProgress() ? null : this.anchorFetchFallback, this.loadIsInProgress() ? null : this.anchorFetchFallback,
this.anchorAutomatic, this.anchorAutomatic,
]; ];
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if (!scrollerNode) return null; if (!scrollerNode) return null;
const layout = this.layoutGetContainerLayout(scrollerNode); const layout = this.layoutGetContainerLayout(scrollerNode);
for (const anchor of candidates) { for (const anchor of candidates) {
@@ -418,7 +418,7 @@ export class ScrollManager {
} else { } else {
this.scrollMergeTo(fixedScrollTop, this.scrollHandle); this.scrollMergeTo(fixedScrollTop, this.scrollHandle);
} }
this.ref.current?.scrollIntoViewNode({ this.ref.current?.revealElement({
node, node,
padding: 16 + this.props.additionalMessagePadding, padding: 16 + this.props.additionalMessagePadding,
callback: this.scrollHandle, callback: this.scrollHandle,
@@ -467,7 +467,7 @@ export class ScrollManager {
} }
anchorUpdateFetch(scrollTop: number, offsetHeight: number, scrollHeight: number, passLayout?: ContainerLayout): void { anchorUpdateFetch(scrollTop: number, offsetHeight: number, scrollHeight: number, passLayout?: ContainerLayout): void {
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if ((!this.anchorFetch && !this.anchorFetchFallback) || !scrollerNode) return; if ((!this.anchorFetch && !this.anchorFetchFallback) || !scrollerNode) return;
const region = this.scrollIsInPlaceholderRegion({scrollTop, offsetHeight, scrollHeight}); const region = this.scrollIsInPlaceholderRegion({scrollTop, offsetHeight, scrollHeight});
const clampTo = region !== ScrollRegion.None ? offsetHeight : undefined; const clampTo = region !== ScrollRegion.None ? offsetHeight : undefined;
@@ -481,7 +481,7 @@ export class ScrollManager {
} }
anchorUpdateAutomatic(scrollTop: number, layout?: ContainerLayout): void { anchorUpdateAutomatic(scrollTop: number, layout?: ContainerLayout): void {
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if (!this.anchorAutomatic || !scrollerNode) return; if (!this.anchorAutomatic || !scrollerNode) return;
const anchorData = this.anchorGetData(this.anchorAutomatic.id, scrollTop, undefined, layout); const anchorData = this.anchorGetData(this.anchorAutomatic.id, scrollTop, undefined, layout);
if (!anchorData) { if (!anchorData) {
@@ -573,7 +573,7 @@ export class ScrollManager {
private focusGetMessageIdInScroller(): string | null { private focusGetMessageIdInScroller(): string | null {
const focusedElement = document.activeElement; const focusedElement = document.activeElement;
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if (!focusedElement || !scrollerNode?.contains(focusedElement)) { if (!focusedElement || !scrollerNode?.contains(focusedElement)) {
return null; return null;
} }
@@ -697,7 +697,7 @@ export class ScrollManager {
this.cacheScrollTop = state.scrollTop; this.cacheScrollTop = state.scrollTop;
this.scrollFixPosition(state.offsetHeight, state.scrollHeight, isAtBottom); this.scrollFixPosition(state.offsetHeight, state.scrollHeight, isAtBottom);
} else { } else {
if (event && event.target !== this.ref.current?.getScrollerNode()) { if (event && event.target !== this.ref.current?.getViewportElement()) {
return; return;
} }
if (this.cacheScrollTop !== state.scrollTop) { if (this.cacheScrollTop !== state.scrollTop) {
@@ -833,7 +833,7 @@ export class ScrollManager {
const element = this.layoutGetElementFromMessageId(targetId); const element = this.layoutGetElementFromMessageId(targetId);
if (element) { if (element) {
const padding = this.jumpGetBreathingRoom(); const padding = this.jumpGetBreathingRoom();
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if (!scrollerNode) return; if (!scrollerNode) return;
const targetScrollTop = this.layoutGetNodeAlignedScrollTop(element, 'center', padding, scrollerNode); const targetScrollTop = this.layoutGetNodeAlignedScrollTop(element, 'center', padding, scrollerNode);
this.scrollMergeTo(targetScrollTop, () => this.jumpComplete(element, jumpToken)); this.scrollMergeTo(targetScrollTop, () => this.jumpComplete(element, jumpToken));
@@ -874,7 +874,7 @@ export class ScrollManager {
const padding = suppressPadding ? 0 : this.jumpGetBreathingRoom(); const padding = suppressPadding ? 0 : this.jumpGetBreathingRoom();
if (newMessagesBar) { if (newMessagesBar) {
if (orientation === 'middle') { if (orientation === 'middle') {
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if (!scrollerNode) { if (!scrollerNode) {
this.scrollTo(Number.MAX_SAFE_INTEGER, animate, onComplete); this.scrollTo(Number.MAX_SAFE_INTEGER, animate, onComplete);
return; return;
@@ -899,7 +899,7 @@ export class ScrollManager {
scrollToBelowUnreadDivider(): void { scrollToBelowUnreadDivider(): void {
const doc = this.scrollGetDocument(); const doc = this.scrollGetDocument();
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
const newMessagesBar = doc?.getElementById('new-messages-bar'); const newMessagesBar = doc?.getElementById('new-messages-bar');
if (!scrollerNode || !newMessagesBar) { if (!scrollerNode || !newMessagesBar) {
this.scrollSetToBottom(); this.scrollSetToBottom();
@@ -963,7 +963,7 @@ export class ScrollManager {
if (this.lifecycleIsDisposed) return; if (this.lifecycleIsDisposed) return;
const {messages, channel} = this.props; const {messages, channel} = this.props;
this.jumpStop(true); this.jumpStop(true);
Dimension.updateChannelDimensions(channel.id, 1, 1, 0); Dimension.recordChannelDimensions(channel.id, 1, 1, 0);
if (messages.hasMoreAfter) { if (messages.hasMoreAfter) {
MessageCommands.jumpToPresent(channel.id, MAX_MESSAGES_PER_CHANNEL); MessageCommands.jumpToPresent(channel.id, MAX_MESSAGES_PER_CHANNEL);
} else { } else {
@@ -1045,7 +1045,7 @@ export class ScrollManager {
layoutApplyShift(heightDelta: number): boolean { layoutApplyShift(heightDelta: number): boolean {
if (this.lifecycleIsDisposed || !this.lifecycleIsInitialized()) return false; if (this.lifecycleIsDisposed || !this.lifecycleIsInitialized()) return false;
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if (!scrollerNode) return false; if (!scrollerNode) return false;
const action = resolveContainerResizeShift({ const action = resolveContainerResizeShift({
heightDelta, heightDelta,
@@ -1079,19 +1079,19 @@ export class ScrollManager {
const {channel} = this.props; const {channel} = this.props;
const placeholderHeight = this.placeholderHeightGet(); const placeholderHeight = this.placeholderHeightGet();
if (this.pinIsAtBottomNow()) { if (this.pinIsAtBottomNow()) {
Dimension.updateChannelDimensions(channel.id, 1, 1, 0, callback); Dimension.recordChannelDimensions(channel.id, 1, 1, 0, callback);
} else { } else {
const {scrollTop, scrollHeight, offsetHeight} = this.scrollGetState(); const {scrollTop, scrollHeight, offsetHeight} = this.scrollGetState();
const adjustedScrollTop = scrollTop - placeholderHeight; const adjustedScrollTop = scrollTop - placeholderHeight;
const adjustedScrollHeight = scrollHeight - placeholderHeight; const adjustedScrollHeight = scrollHeight - placeholderHeight;
Dimension.updateChannelDimensions(channel.id, adjustedScrollTop, adjustedScrollHeight, offsetHeight, callback); Dimension.recordChannelDimensions(channel.id, adjustedScrollTop, adjustedScrollHeight, offsetHeight, callback);
} }
} }
focusOnMessage(messageId: string): void { focusOnMessage(messageId: string): void {
const element = this.layoutGetElementFromMessageId(messageId); const element = this.layoutGetElementFromMessageId(messageId);
if (!element) return; if (!element) return;
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
if (!scrollerNode) return; if (!scrollerNode) return;
const elementOffset = this.layoutGetOffsetTop(element, scrollerNode); const elementOffset = this.layoutGetOffsetTop(element, scrollerNode);
const elementHeight = element.offsetHeight; const elementHeight = element.offsetHeight;
@@ -1125,11 +1125,11 @@ export class ScrollManager {
} }
scrollPageUp(animate = false): void { scrollPageUp(animate = false): void {
this.ref.current?.scrollPageUp({animate}); this.ref.current?.pageBackward({animate});
} }
scrollPageDown(animate = false): void { scrollPageDown(animate = false): void {
this.ref.current?.scrollPageDown({animate}); this.ref.current?.pageForward({animate});
} }
scrollToMessage(messageId: string, animate = true): void { scrollToMessage(messageId: string, animate = true): void {
@@ -1143,7 +1143,7 @@ export class ScrollManager {
return; return;
} }
const element = this.layoutGetElementFromMessageId(messageId); const element = this.layoutGetElementFromMessageId(messageId);
const scrollerNode = scrollerHandle.getScrollerNode(); const scrollerNode = scrollerHandle.getViewportElement();
this.pinIsAtBottom = false; this.pinIsAtBottom = false;
const jumpToken = this.jumpBegin(this.props.messages.jumpSequenceId); const jumpToken = this.jumpBegin(this.props.messages.jumpSequenceId);
const shouldContinue = () => this.jumpIsCurrent(jumpToken); const shouldContinue = () => this.jumpIsCurrent(jumpToken);
@@ -1174,7 +1174,7 @@ export class ScrollManager {
return; return;
} }
const {scrollTop, offsetHeight, scrollHeight} = this.scrollGetState(); const {scrollTop, offsetHeight, scrollHeight} = this.scrollGetState();
const scrollerNode = this.ref.current?.getScrollerNode(); const scrollerNode = this.ref.current?.getViewportElement();
const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined; const layout = scrollerNode ? this.layoutGetContainerLayout(scrollerNode) : undefined;
this.pinPreUpdateState = this.pinComputeState({scrollTop, offsetHeight, scrollHeight}); this.pinPreUpdateState = this.pinComputeState({scrollTop, offsetHeight, scrollHeight});
this.anchorUpdateFocus(focusId, scrollTop, offsetHeight, layout); this.anchorUpdateFocus(focusId, scrollTop, offsetHeight, layout);
@@ -1247,7 +1247,7 @@ export class ScrollManager {
if (focusId != null && prevFocusId !== focusId) { if (focusId != null && prevFocusId !== focusId) {
const el = this.layoutGetElementFromMessageId(focusId); const el = this.layoutGetElementFromMessageId(focusId);
if (el) { if (el) {
this.ref.current?.scrollIntoViewNode({ this.ref.current?.revealElement({
node: el, node: el,
padding: 16 + this.props.additionalMessagePadding, padding: 16 + this.props.additionalMessagePadding,
callback: this.scrollHandle, callback: this.scrollHandle,
@@ -496,7 +496,7 @@ class ReadStates {
const decision = resolveReadStateIncomingMessageDecision({ const decision = resolveReadStateIncomingMessageDecision({
isCurrentUserAuthor: currentUser != null && action.message.author.id === currentUser.id, isCurrentUserAuthor: currentUser != null && action.message.author.id === currentUser.id,
automaticAckEnabled: this.isAutomaticAckEnabled(action.channelId), automaticAckEnabled: this.isAutomaticAckEnabled(action.channelId),
isAtBottom: Dimension.isAtBottom(action.channelId), isAtBottom: Dimension.channelPinnedToEnd(action.channelId),
authorBlocked, authorBlocked,
hadUnreadOrMentions, hadUnreadOrMentions,
readStateKnown: state.readStateKnown, readStateKnown: state.readStateKnown,
@@ -341,7 +341,7 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
shouldScrollToSelection.current = false; shouldScrollToSelection.current = false;
const node = rowRefs.current[keyboardFocusIndex]; const node = rowRefs.current[keyboardFocusIndex];
if (node) { if (node) {
scrollerRef.current?.scrollIntoViewNode({node: node as HTMLElement, padding: 32}); scrollerRef.current?.revealElement({node: node as HTMLElement, padding: 32});
} }
}, [keyboardFocusIndex, activeTab]); }, [keyboardFocusIndex, activeTab]);
const sections = useMemo(() => createSections(results), [results]); const sections = useMemo(() => createSections(results), [results]);
@@ -391,7 +391,7 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
shouldScrollToSelection.current = false; shouldScrollToSelection.current = false;
const node = rowRefs.current[keyboardFocusIndex]; const node = rowRefs.current[keyboardFocusIndex];
if (node) { if (node) {
scrollerRef.current?.scrollIntoViewNode({node: node as HTMLElement, padding: 32}); scrollerRef.current?.revealElement({node: node as HTMLElement, padding: 32});
} }
}, [keyboardFocusIndex]); }, [keyboardFocusIndex]);
const sections = useMemo(() => createSections(results), [results]); const sections = useMemo(() => createSections(results), [results]);
@@ -28,6 +28,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import {flushSync} from 'react-dom';
export interface ScrollerState { export interface ScrollerState {
scrollTop: number; scrollTop: number;
@@ -39,42 +40,43 @@ export interface ScrollerState {
} }
export interface ScrollerHandle { export interface ScrollerHandle {
getScrollerNode(): HTMLElement | null; getViewportElement(): HTMLElement | null;
getScrollerState(): ScrollerState; readViewportMetrics(): ScrollerState;
cancelScroll(): void; cancelScroll(): void;
scrollTo(options: {to: number; animate?: boolean; callback?: () => void}): void; scrollTo(options: {to: number; animate?: boolean; callback?: () => void}): void;
mergeTo(options: {to: number; callback?: () => void}): void; mergeTo(options: {to: number; callback?: () => void}): void;
scrollIntoViewRect(options: { revealRange(options: {
start: number; start: number;
end: number; end: number;
shouldScrollToStart?: boolean; preferStartEdge?: boolean;
alignment?: 'start' | 'center' | 'end' | 'nearest'; alignment?: 'start' | 'center' | 'end' | 'nearest';
padding?: number; padding?: number;
animate?: boolean; animate?: boolean;
callback?: () => void; callback?: () => void;
}): void; }): void;
scrollIntoViewNode(options: { revealElement(options: {
node: HTMLElement; node: HTMLElement;
shouldScrollToStart?: boolean; preferStartEdge?: boolean;
alignment?: 'start' | 'center' | 'end' | 'nearest'; alignment?: 'start' | 'center' | 'end' | 'nearest';
padding?: number; padding?: number;
animate?: boolean; animate?: boolean;
callback?: () => void; callback?: () => void;
}): void; }): void;
scrollPageUp(options?: {animate?: boolean; callback?: () => void}): void; pageBackward(options?: {animate?: boolean; callback?: () => void}): void;
scrollPageDown(options?: {animate?: boolean; callback?: () => void}): void; pageForward(options?: {animate?: boolean; callback?: () => void}): void;
scrollToTop(options?: {animate?: boolean; callback?: () => void}): void; jumpToStartEdge(options?: {animate?: boolean; callback?: () => void}): void;
scrollToBottom(options?: {animate?: boolean; callback?: () => void}): void; jumpToEndEdge(options?: {animate?: boolean; callback?: () => void}): void;
isScrolledToTop(): boolean; atStartEdge(): boolean;
isScrolledToBottom(): boolean; atEndEdge(): boolean;
getDistanceFromTop(): number; remainingToStartEdge(): number;
getDistanceFromBottom(): number; remainingToEndEdge(): number;
} }
type ScrollAxis = 'vertical' | 'horizontal'; type ScrollAxis = 'vertical' | 'horizontal';
type ScrollOverflow = 'scroll' | 'auto' | 'hidden'; type ScrollOverflow = 'scroll' | 'auto' | 'hidden';
type ScrollbarTrackMode = 'overlay' | 'reserve'; type ScrollbarTrackMode = 'overlay' | 'reserve';
const LINE_DELTA_PX = 16; const LINE_DELTA_PX = 16;
const RESIZE_OBSERVE_OPTIONS = Object.freeze({box: 'border-box'} as const);
interface ScrollerProps interface ScrollerProps
extends Omit< extends Omit<
@@ -323,6 +325,13 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
}, },
[getMaxScroll], [getMaxScroll],
); );
const resolveScrollTarget = useCallback(
(node: HTMLDivElement, value: number): number => {
const max = getMaxScroll(node);
return value >= max ? max + 1 : Math.max(0, value);
},
[getMaxScroll],
);
const springScrollTo = useCallback( const springScrollTo = useCallback(
(node: HTMLDivElement, target: number, callback?: () => void, config: SpringConfig = DEFAULT_SPRING) => { (node: HTMLDivElement, target: number, callback?: () => void, config: SpringConfig = DEFAULT_SPRING) => {
cancelAnimation(); cancelAnimation();
@@ -368,20 +377,24 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
[cancelAnimation, clampToRange, getScrollPos, setScrollPos], [cancelAnimation, clampToRange, getScrollPos, setScrollPos],
); );
useLayoutEffect(() => { useLayoutEffect(() => {
const containerEl = rootRef.current; const containerEl = scrollRef.current;
const contentEl = contentRef.current; const contentEl = contentRef.current;
if (!containerEl || !contentEl) return; if (!containerEl || !contentEl) return;
const ownerWindow = containerEl.ownerDocument.defaultView ?? window; const ownerWindow = containerEl.ownerDocument.defaultView ?? window;
const containerObserver = new ownerWindow.ResizeObserver((entries) => { const containerObserver = new ownerWindow.ResizeObserver((entries) => {
scheduleThumbRefresh(); scheduleThumbRefresh();
for (const entry of entries) { for (const entry of entries) {
flushSync(() => {
onResizeRef.current?.(entry, 'container'); onResizeRef.current?.(entry, 'container');
});
} }
}); });
const contentObserver = new ownerWindow.ResizeObserver((entries) => { const contentObserver = new ownerWindow.ResizeObserver((entries) => {
scheduleThumbRefresh(); scheduleThumbRefresh();
for (const entry of entries) { for (const entry of entries) {
flushSync(() => {
onResizeRef.current?.(entry, 'content'); onResizeRef.current?.(entry, 'content');
});
} }
}); });
const childObserver = new ownerWindow.ResizeObserver((entries) => { const childObserver = new ownerWindow.ResizeObserver((entries) => {
@@ -417,8 +430,8 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
refreshThumbState(); refreshThumbState();
}); });
}); });
containerObserver.observe(containerEl); containerObserver.observe(containerEl, RESIZE_OBSERVE_OPTIONS);
contentObserver.observe(contentEl); contentObserver.observe(contentEl, RESIZE_OBSERVE_OPTIONS);
syncChildObservers(); syncChildObservers();
childListObserver.observe(contentEl, {childList: true, subtree: true}); childListObserver.observe(contentEl, {childList: true, subtree: true});
refreshThumbState(); refreshThumbState();
@@ -470,8 +483,8 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
useImperativeHandle( useImperativeHandle(
forwardedRef, forwardedRef,
() => ({ () => ({
getScrollerNode: () => scrollRef.current, getViewportElement: () => scrollRef.current,
getScrollerState: () => getScrollState(scrollRef.current), readViewportMetrics: () => getScrollState(scrollRef.current),
cancelScroll() { cancelScroll() {
cancelAnimation(); cancelAnimation();
}, },
@@ -481,7 +494,7 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
callback?.(); callback?.();
return; return;
} }
const clampedTo = clampToRange(node, to); const clampedTo = resolveScrollTarget(node, to);
if (!animate || !shouldAnimateScroll()) { if (!animate || !shouldAnimateScroll()) {
cancelAnimation(); cancelAnimation();
setScrollPos(node, clampedTo); setScrollPos(node, clampedTo);
@@ -496,11 +509,19 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
callback?.(); callback?.();
return; return;
} }
cancelAnimation(); const merged = clampToRange(node, to);
setScrollPos(node, clampToRange(node, to)); const anim = animRef.current;
if (anim == null) {
setScrollPos(node, merged);
callback?.();
return;
}
const delta = merged - getScrollPos(node);
setScrollPos(node, merged);
anim.target += delta;
callback?.(); callback?.();
}, },
scrollIntoViewRect({start, end, shouldScrollToStart = false, alignment, padding = 0, animate = false, callback}) { revealRange({start, end, preferStartEdge = false, alignment, padding = 0, animate = false, callback}) {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) { if (!node) {
callback?.(); callback?.();
@@ -508,7 +529,7 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
} }
const scrollPos = getScrollPos(node); const scrollPos = getScrollPos(node);
const viewportSize = orientation === 'vertical' ? node.offsetHeight : node.offsetWidth; const viewportSize = orientation === 'vertical' ? node.offsetHeight : node.offsetWidth;
const resolvedAlignment = alignment ?? (shouldScrollToStart ? 'start' : 'nearest'); const resolvedAlignment = alignment ?? (preferStartEdge ? 'start' : 'nearest');
if (resolvedAlignment === 'center') { if (resolvedAlignment === 'center') {
const targetScroll = start - (viewportSize - (end - start)) / 2; const targetScroll = start - (viewportSize - (end - start)) / 2;
this.scrollTo({to: targetScroll, animate, callback}); this.scrollTo({to: targetScroll, animate, callback});
@@ -537,14 +558,7 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
} }
this.scrollTo({to: targetScroll, animate, callback}); this.scrollTo({to: targetScroll, animate, callback});
}, },
scrollIntoViewNode({ revealElement({node: targetNode, preferStartEdge = false, alignment, padding = 0, animate = false, callback}) {
node: targetNode,
shouldScrollToStart = false,
alignment,
padding = 0,
animate = false,
callback,
}) {
const container = scrollRef.current; const container = scrollRef.current;
if (!container) { if (!container) {
callback?.(); callback?.();
@@ -552,17 +566,17 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
} }
const offset = measureElementOffset(targetNode, orientation, container); const offset = measureElementOffset(targetNode, orientation, container);
const size = orientation === 'vertical' ? targetNode.offsetHeight : targetNode.offsetWidth; const size = orientation === 'vertical' ? targetNode.offsetHeight : targetNode.offsetWidth;
this.scrollIntoViewRect({ this.revealRange({
start: offset, start: offset,
end: offset + size, end: offset + size,
shouldScrollToStart, preferStartEdge,
alignment, alignment,
padding, padding,
animate, animate,
callback, callback,
}); });
}, },
scrollPageUp({animate = false, callback} = {}) { pageBackward({animate = false, callback} = {}) {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) { if (!node) {
callback?.(); callback?.();
@@ -573,7 +587,7 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
const target = Math.max(0, scrollPos - 0.9 * viewportSize); const target = Math.max(0, scrollPos - 0.9 * viewportSize);
this.scrollTo({to: target, animate, callback}); this.scrollTo({to: target, animate, callback});
}, },
scrollPageDown({animate = false, callback} = {}) { pageForward({animate = false, callback} = {}) {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) { if (!node) {
callback?.(); callback?.();
@@ -585,10 +599,10 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
const target = Math.min(maxScroll, scrollPos + 0.9 * viewportSize); const target = Math.min(maxScroll, scrollPos + 0.9 * viewportSize);
this.scrollTo({to: target, animate, callback}); this.scrollTo({to: target, animate, callback});
}, },
scrollToTop({animate = false, callback} = {}) { jumpToStartEdge({animate = false, callback} = {}) {
this.scrollTo({to: 0, animate, callback}); this.scrollTo({to: 0, animate, callback});
}, },
scrollToBottom({animate = false, callback} = {}) { jumpToEndEdge({animate = false, callback} = {}) {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) { if (!node) {
callback?.(); callback?.();
@@ -596,24 +610,24 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
} }
this.scrollTo({to: getMaxScroll(node), animate, callback}); this.scrollTo({to: getMaxScroll(node), animate, callback});
}, },
isScrolledToTop() { atStartEdge() {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) return false; if (!node) return false;
return getScrollPos(node) === 0; return getScrollPos(node) === 0;
}, },
isScrolledToBottom() { atEndEdge() {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) return false; if (!node) return false;
const scrollPos = getScrollPos(node); const scrollPos = getScrollPos(node);
const maxScroll = getMaxScroll(node); const maxScroll = getMaxScroll(node);
return scrollPos >= maxScroll; return scrollPos >= maxScroll;
}, },
getDistanceFromTop() { remainingToStartEdge() {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) return 0; if (!node) return 0;
return Math.max(0, getScrollPos(node)); return Math.max(0, getScrollPos(node));
}, },
getDistanceFromBottom() { remainingToEndEdge() {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) return 0; if (!node) return 0;
const scrollPos = getScrollPos(node); const scrollPos = getScrollPos(node);
@@ -621,7 +635,16 @@ export const Scroller = forwardRef<ScrollerHandle, ScrollerProps>(function Scrol
return Math.max(0, maxScroll - scrollPos); return Math.max(0, maxScroll - scrollPos);
}, },
}), }),
[orientation, cancelAnimation, clampToRange, getMaxScroll, getScrollPos, setScrollPos, springScrollTo], [
orientation,
cancelAnimation,
clampToRange,
getMaxScroll,
getScrollPos,
resolveScrollTarget,
setScrollPos,
springScrollTo,
],
); );
const handleScroll = useCallback( const handleScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => { (event: UIEvent<HTMLDivElement>) => {
@@ -71,7 +71,7 @@ function findScrollContainer(el: HTMLElement, selector: string | undefined): HTM
function resolveScrollerNode(scrollerRef: RefObject<ScrollerHandle | null>): HTMLElement | null { function resolveScrollerNode(scrollerRef: RefObject<ScrollerHandle | null>): HTMLElement | null {
const scroller = scrollerRef.current; const scroller = scrollerRef.current;
if (scroller == null) return null; if (scroller == null) return null;
return scroller.getScrollerNode(); return scroller.getViewportElement();
} }
function compareListScrollAnchorPositions(left: ListScrollAnchorPosition, right: ListScrollAnchorPosition): number { function compareListScrollAnchorPositions(left: ListScrollAnchorPosition, right: ListScrollAnchorPosition): number {
+10 -10
View File
@@ -36,14 +36,14 @@ class Dimension {
constructor() { constructor() {
makeObservable(this, { makeObservable(this, {
updateChannelDimensions: action, recordChannelDimensions: action,
updateGuildDimensions: action, updateGuildDimensions: action,
updateGuildListDimensions: action, updateGuildListDimensions: action,
clearChannelDimensions: action, forgetChannelDimensions: action,
}); });
} }
percentageScrolled(channelId: string): number { scrollProgressRatio(channelId: string): number {
const dimensions = this.channelDimensions.get(channelId); const dimensions = this.channelDimensions.get(channelId);
if (dimensions != null) { if (dimensions != null) {
const {scrollTop, scrollHeight} = dimensions; const {scrollTop, scrollHeight} = dimensions;
@@ -52,11 +52,11 @@ class Dimension {
return 1; return 1;
} }
getChannelDimensions(channelId: string): ChannelDimensions | undefined { channelDimensionsFor(channelId: string): ChannelDimensions | undefined {
return this.channelDimensions.get(channelId); return this.channelDimensions.get(channelId);
} }
getGuildDimensions(guildId: string): GuildDimensions { guildDimensionsFor(guildId: string): GuildDimensions {
const existing = this.guildDimensions.get(guildId); const existing = this.guildDimensions.get(guildId);
if (existing != null) { if (existing != null) {
return existing; return existing;
@@ -64,11 +64,11 @@ class Dimension {
return createDefaultGuildDimensions(guildId); return createDefaultGuildDimensions(guildId);
} }
getGuildListDimensions(): GuildListDimensions { currentGuildListDimensions(): GuildListDimensions {
return this.guildListDimensions.get(); return this.guildListDimensions.get();
} }
isAtBottom(channelId: string): boolean { channelPinnedToEnd(channelId: string): boolean {
const dimensions = this.channelDimensions.get(channelId); const dimensions = this.channelDimensions.get(channelId);
if (dimensions == null) { if (dimensions == null) {
return true; return true;
@@ -77,7 +77,7 @@ class Dimension {
return scrollTop >= scrollHeight - offsetHeight - BOTTOM_TOLERANCE; return scrollTop >= scrollHeight - offsetHeight - BOTTOM_TOLERANCE;
} }
updateChannelDimensions( recordChannelDimensions(
channelId: string, channelId: string,
scrollTop: number | null, scrollTop: number | null,
scrollHeight: number | null, scrollHeight: number | null,
@@ -129,7 +129,7 @@ class Dimension {
this.guildListDimensions.set({scrollTop}); this.guildListDimensions.set({scrollTop});
} }
clearChannelDimensions(channelId: string): void { forgetChannelDimensions(channelId: string): void {
this.channelDimensions.delete(channelId); this.channelDimensions.delete(channelId);
} }
@@ -142,7 +142,7 @@ class Dimension {
} }
handleCallCreate(channelId: string): void { handleCallCreate(channelId: string): void {
this.clearChannelDimensions(channelId); this.forgetChannelDimensions(channelId);
} }
} }
@@ -46,7 +46,7 @@ export function useWindowFocusVideoControl({
} }
}; };
const handleBlur = () => { const handleBlur = () => {
const node = scrollerRefRef.current.current?.getScrollerNode(); const node = scrollerRefRef.current.current?.getViewportElement();
if (document.activeElement instanceof HTMLElement && node?.contains(document.activeElement)) { if (document.activeElement instanceof HTMLElement && node?.contains(document.activeElement)) {
const scrollTop = node.scrollTop; const scrollTop = node.scrollTop;
document.activeElement.blur(); document.activeElement.blur();