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