diff --git a/fluxer_app/src/features/channel/components/MobileStickersPicker.tsx b/fluxer_app/src/features/channel/components/MobileStickersPicker.tsx index 24414af85..84cc278d6 100644 --- a/fluxer_app/src/features/channel/components/MobileStickersPicker.tsx +++ b/fluxer_app/src/features/channel/components/MobileStickersPicker.tsx @@ -13,6 +13,12 @@ import {PickerEmptyState} from '@app/features/channel/components/shared/PickerEm import {useStickerCategories} from '@app/features/channel/components/sticker_picker/hooks/useStickerCategories'; import {useVirtualRows} from '@app/features/channel/components/sticker_picker/hooks/useVirtualRows'; import {StickerPickerCategoryList} from '@app/features/channel/components/sticker_picker/StickerPickerCategoryList'; +import { + buildStickerRowOffsets, + getStickerRowHeight, + getStickerRowWindow, + type StickerRowKind, +} from '@app/features/channel/components/sticker_picker/StickerPickerConstants'; import {StickerPickerInspector} from '@app/features/channel/components/sticker_picker/StickerPickerInspector'; import {StickerPickerSearchBar} from '@app/features/channel/components/sticker_picker/StickerPickerSearchBar'; import {VirtualRowRenderer} from '@app/features/channel/components/sticker_picker/VirtualRow'; @@ -34,12 +40,13 @@ import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData'; import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; +import {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils'; import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import {msg} from '@lingui/core/macro'; import {Plural, Trans, useLingui} from '@lingui/react/macro'; import {SmileySadIcon, StickerIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; -import {useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; +import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; const NO_STICKERS_AVAILABLE_DESCRIPTOR = msg({ message: 'No stickers yet', @@ -73,9 +80,10 @@ export const MobileStickersPicker = observer( const scrollerRef = useRef(null); const searchInputRef = useRef(null); const stickerRefs = useRef>(new Map()); - const {viewportSize, handleResize} = useScrollerViewport(scrollerRef); + const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef); const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null; - const categoryRefs = useRef>(new Map()); + const rowListRef = useRef(null); + const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0, gridWidth: 0}); const [stickerDataVersion, setStickerDataVersion] = useState(0); const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version); const {shouldAnimate: shouldAnimateStickerPreview} = useStickerAnimation(); @@ -95,10 +103,10 @@ export const MobileStickersPicker = observer( src={AvatarUtils.getStickerURL({ id: sticker.id, animated: shouldAnimateStickerPreview, + isAnimatable: sticker.animated, size: 320, })} alt={sticker.name} - loading="lazy" data-flx="channel.mobile-stickers-picker.render-sticker-preview-item.img" /> @@ -170,11 +178,58 @@ export const MobileStickersPicker = observer( const isSearching = searchTerm.trim().length > 0; const showPremiumUpsell = shouldShowPremiumFeatures() && shouldShowStickerPremiumUpsell(channel) && !isSearching && lockedStickerCount > 0; - const handleCategoryClick = (category: string) => { - const element = categoryRefs.current.get(category); - if (element) { - scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); + const {stickerRowIndexes, categoryRowIndexes} = useMemo(() => { + const indexes = new Array(pickerRows.length); + const categories = new Map(); + let stickerRowIndex = 0; + for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) { + const row = pickerRows[rowIndex]; + indexes[rowIndex] = stickerRowIndex; + if (row.type === 'sticker-row') { + stickerRowIndex += 1; + } else { + categories.set(row.category, rowIndex); + } } + return {stickerRowIndexes: indexes, categoryRowIndexes: categories}; + }, [pickerRows]); + const remScale = getAppRemScale(); + const stickerRowHeight = useMemo( + () => getStickerRowHeight(listMetrics.gridWidth, gridColumns, remScale), + [listMetrics.gridWidth, gridColumns, remScale, zoomLevel], + ); + const rowOffsets = useMemo(() => { + const rowKinds = pickerRows.map((row): StickerRowKind => row.type); + return buildStickerRowOffsets(rowKinds, {remScale, stickerRowHeight, sectionGap: 0}); + }, [pickerRows, remScale, stickerRowHeight, zoomLevel]); + const contentHeight = rowOffsets[rowOffsets.length - 1]!; + useLayoutEffect(() => { + const rowListNode = rowListRef.current; + const scrollerNode = scrollerRef.current?.getViewportElement(); + if (!rowListNode || !scrollerNode) { + return; + } + const origin = Math.round( + rowListNode.getBoundingClientRect().top - scrollerNode.getBoundingClientRect().top + scrollerNode.scrollTop, + ); + const viewportHeight = scrollerNode.clientHeight; + const gridWidth = rowListNode.clientWidth; + setListMetrics((current) => + current.origin === origin && current.viewportHeight === viewportHeight && current.gridWidth === gridWidth + ? current + : {origin, viewportHeight, gridWidth}, + ); + }); + const rowWindow = useMemo( + () => getStickerRowWindow(rowOffsets, scrollTop - listMetrics.origin, listMetrics.viewportHeight), + [rowOffsets, scrollTop, listMetrics], + ); + const handleCategoryClick = (category: string) => { + const rowIndex = categoryRowIndexes.get(category); + if (rowIndex == null) { + return; + } + scrollerRef.current?.scrollTo({to: listMetrics.origin + rowOffsets[rowIndex]!}); }; const handleHover = (sticker: GuildSticker | null) => { setHoveredSticker(sticker); @@ -252,6 +307,7 @@ export const MobileStickersPicker = observer( ref={scrollerRef} className={`${mobileStyles.list} ${mobileStyles.listWrapper}`} key="mobile-stickers-picker-scroller" + onScroll={handleScroll} onResize={handleResize} data-flx="channel.mobile-stickers-picker.scroller" > @@ -264,39 +320,41 @@ export const MobileStickersPicker = observer( data-flx="channel.mobile-stickers-picker.premium-upsell-banner" /> )} - {pickerRows.map((row, index) => { - const stickerRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'sticker-row').length; - return ( -
{ - if (el && 'category' in row) { - categoryRefs.current.set(row.category, el); - } - } - : undefined - } - data-flx="channel.mobile-stickers-picker.div--5" - > - -
- ); - })} +
+ {pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => { + const rowIndex = rowWindow.firstRow + windowIndex; + return ( +
+ +
+ ); + })} +
diff --git a/fluxer_app/src/features/channel/components/StickersPicker.tsx b/fluxer_app/src/features/channel/components/StickersPicker.tsx index 4d9ed4246..44279893a 100644 --- a/fluxer_app/src/features/channel/components/StickersPicker.tsx +++ b/fluxer_app/src/features/channel/components/StickersPicker.tsx @@ -1,16 +1,25 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import Accessibility from '@app/features/accessibility/state/Accessibility'; import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAutofocus'; import styles from '@app/features/channel/components/EmojiPicker.module.css'; import gifStyles from '@app/features/channel/components/GifPicker.module.css'; import {PremiumUpsellBanner} from '@app/features/channel/components/PremiumUpsellBanner'; import premiumStyles from '@app/features/channel/components/PremiumUpsellBanner.module.css'; +import {useScrollerViewport} from '@app/features/channel/components/pickers/shared/useScrollerViewport'; import {PickerEmptyState} from '@app/features/channel/components/shared/PickerEmptyState'; import {useStickerCategories} from '@app/features/channel/components/sticker_picker/hooks/useStickerCategories'; import {useVirtualRows} from '@app/features/channel/components/sticker_picker/hooks/useVirtualRows'; import {StickerPickerCategoryList} from '@app/features/channel/components/sticker_picker/StickerPickerCategoryList'; -import {STICKERS_PER_ROW} from '@app/features/channel/components/sticker_picker/StickerPickerConstants'; +import { + buildStickerRowOffsets, + getStickerRowHeight, + getStickerRowWindow, + STICKER_SECTION_GAP, + STICKERS_PER_ROW, + type StickerRowKind, +} from '@app/features/channel/components/sticker_picker/StickerPickerConstants'; import {StickerPickerInspector} from '@app/features/channel/components/sticker_picker/StickerPickerInspector'; import {StickerPickerSearchBar} from '@app/features/channel/components/sticker_picker/StickerPickerSearchBar'; import {VirtualRowWrapper} from '@app/features/channel/components/sticker_picker/VirtualRow'; @@ -29,13 +38,14 @@ import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData'; import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; +import {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils'; import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import {msg} from '@lingui/core/macro'; import {Plural, Trans, useLingui} from '@lingui/react/macro'; import {SmileySadIcon, StickerIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import type React from 'react'; -import {useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; +import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; const NO_STICKERS_AVAILABLE_DESCRIPTOR = msg({ message: 'No stickers yet', @@ -64,10 +74,12 @@ export const StickersPicker = observer( const [selectedColumn, setSelectedColumn] = useState(-1); const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false); const scrollerRef = useRef(null); + const {scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef); const searchInputRef = useRef(null); const stickerRefs = useRef>(new Map()); const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null; - const categoryRefs = useRef>(new Map()); + const rowListRef = useRef(null); + const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0, gridWidth: 0}); const [stickerDataVersion, setStickerDataVersion] = useState(0); const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version); const {shouldAnimate: shouldAnimateStickerPreview} = useStickerAnimation(); @@ -87,10 +99,10 @@ export const StickersPicker = observer( src={AvatarUtils.getStickerURL({ id: sticker.id, animated: shouldAnimateStickerPreview, + isAnimatable: sticker.animated, size: 320, })} alt={sticker.name} - loading="lazy" data-flx="channel.stickers-picker.render-sticker-preview-item.img" /> @@ -169,17 +181,61 @@ export const StickersPicker = observer( } return result; }, [pickerRows]); + const {stickerRowIndexes, stickerRowStarts, categoryRowIndexes} = useMemo(() => { + const indexes = new Array(pickerRows.length); + const starts: Array = []; + const categories = new Map(); + let stickerRowIndex = 0; + for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) { + const row = pickerRows[rowIndex]; + indexes[rowIndex] = stickerRowIndex; + if (row.type === 'sticker-row') { + starts.push(rowIndex); + stickerRowIndex += 1; + } else { + categories.set(row.category, rowIndex); + } + } + return {stickerRowIndexes: indexes, stickerRowStarts: starts, categoryRowIndexes: categories}; + }, [pickerRows]); + const zoomLevel = Accessibility.zoomLevel; + const remScale = getAppRemScale(); + const stickerRowHeight = useMemo( + () => getStickerRowHeight(listMetrics.gridWidth, STICKERS_PER_ROW, remScale), + [listMetrics.gridWidth, remScale, zoomLevel], + ); + const rowOffsets = useMemo(() => { + const rowKinds = pickerRows.map((row): StickerRowKind => row.type); + return buildStickerRowOffsets(rowKinds, {remScale, stickerRowHeight, sectionGap: STICKER_SECTION_GAP}); + }, [pickerRows, remScale, stickerRowHeight, zoomLevel]); + const contentHeight = rowOffsets[rowOffsets.length - 1]!; + useLayoutEffect(() => { + const rowListNode = rowListRef.current; + const scrollerNode = scrollerRef.current?.getViewportElement(); + if (!rowListNode || !scrollerNode) { + return; + } + const origin = Math.round( + rowListNode.getBoundingClientRect().top - scrollerNode.getBoundingClientRect().top + scrollerNode.scrollTop, + ); + const viewportHeight = scrollerNode.clientHeight; + const gridWidth = rowListNode.clientWidth; + setListMetrics((current) => + current.origin === origin && current.viewportHeight === viewportHeight && current.gridWidth === gridWidth + ? current + : {origin, viewportHeight, gridWidth}, + ); + }); + const rowWindow = useMemo( + () => getStickerRowWindow(rowOffsets, scrollTop - listMetrics.origin, listMetrics.viewportHeight), + [rowOffsets, scrollTop, listMetrics], + ); const handleCategoryClick = (category: string) => { - const element = categoryRefs.current.get(category); - if (element) { - scrollerRef.current?.revealElement({node: element, preferStartEdge: true}); - } - }; - const handleHover = (sticker: GuildSticker | null, row?: number, column?: number) => { - setHoveredSticker(sticker); - if (sticker && row !== undefined && column !== undefined) { - handleSelectionChange(row, column, false); + const rowIndex = categoryRowIndexes.get(category); + if (rowIndex == null) { + return; } + scrollerRef.current?.scrollTo({to: listMetrics.origin + rowOffsets[rowIndex]!}); }; const handleStickerSelect = useCallback( (sticker: GuildSticker, shiftKey?: boolean) => { @@ -214,11 +270,31 @@ export const StickersPicker = observer( }, [pickerRows], ); + const handleHover = useCallback( + (sticker: GuildSticker | null, row?: number, column?: number) => { + setHoveredSticker(sticker); + if (sticker && row !== undefined && column !== undefined) { + handleSelectionChange(row, column, false); + } + }, + [handleSelectionChange], + ); useEffect(() => { if (renderedStickers.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredSticker) { handleSelectionChange(0, 0, false); } }, [renderedStickers, selectedRow, selectedColumn, hoveredSticker, handleSelectionChange]); + useEffect(() => { + if (!shouldScrollOnSelection || selectedRow < 0) { + return; + } + const rowIndex = stickerRowStarts[selectedRow]; + if (rowIndex == null) { + return; + } + const start = listMetrics.origin + rowOffsets[rowIndex]!; + scrollerRef.current?.revealRange({start, end: start + stickerRowHeight}); + }, [shouldScrollOnSelection, selectedRow, stickerRowStarts, rowOffsets, listMetrics.origin, stickerRowHeight]); const handleSelectSticker = useCallback( (row: number | null, column: number | null, event?: React.KeyboardEvent) => { if (row === null || column === null) { @@ -283,6 +359,8 @@ export const StickersPicker = observer( className={`${styles.list} ${styles.listWrapper}`} fade={false} key="stickers-picker-scroller" + onScroll={handleScroll} + onResize={handleResize} data-flx="channel.stickers-picker.list" > {showPremiumUpsell && ( @@ -294,41 +372,41 @@ export const StickersPicker = observer( data-flx="channel.stickers-picker.premium-upsell-banner" /> )} - {pickerRows.map((row, index) => { - const stickerRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'sticker-row').length; - const needsSpacingAfter = row.type === 'sticker-row' && pickerRows[index + 1]?.type === 'header'; - return ( -
{ - if (el && 'category' in row) { - categoryRefs.current.set(row.category, el); - } - } - : undefined - } - style={row.type === 'sticker-row' && needsSpacingAfter ? {marginBottom: '12px'} : undefined} - data-flx="channel.stickers-picker.div--3" - > - -
- ); - })} +
+ {pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => { + const rowIndex = rowWindow.firstRow + windowIndex; + return ( +
+ +
+ ); + })} +
{renderedStickers.length === 0 && (
diff --git a/fluxer_app/src/features/channel/components/sticker_picker/StickerPickerConstants.ts b/fluxer_app/src/features/channel/components/sticker_picker/StickerPickerConstants.ts index 4634a296b..5bfca2e3c 100644 --- a/fluxer_app/src/features/channel/components/sticker_picker/StickerPickerConstants.ts +++ b/fluxer_app/src/features/channel/components/sticker_picker/StickerPickerConstants.ts @@ -2,3 +2,91 @@ export const STICKERS_PER_ROW = 4; export const STICKERS_PER_ROW_MOBILE = 5; +export const STICKER_CATEGORY_HEADER_HEIGHT = 32; +export const STICKER_SECTION_GAP = 12; + +const STICKER_CATEGORY_HEADER_GAP = 8; +const STICKER_GRID_GAP = 8; +const STICKER_GRID_BLOCK_END_PADDING = 8; +const STICKER_ROW_WINDOW_LEAD_ROWS = 2; + +export type StickerRowKind = 'header' | 'sticker-row'; + +export interface StickerRowMetrics { + remScale: number; + stickerRowHeight: number; + sectionGap: number; +} + +export interface StickerRowWindow { + firstRow: number; + lastRow: number; +} + +export function getStickerRowHeight(gridWidth: number, gridColumns: number, remScale: number): number { + if (!(gridWidth > 0) || gridColumns <= 0) { + return 0; + } + const cellWidth = Math.max(0, (gridWidth - STICKER_GRID_GAP * remScale * (gridColumns - 1)) / gridColumns); + return cellWidth + STICKER_GRID_BLOCK_END_PADDING * remScale; +} + +export function buildStickerRowOffsets( + rowKinds: ReadonlyArray, + {remScale, stickerRowHeight, sectionGap}: StickerRowMetrics, +): Array { + const rowOffsets = new Array(rowKinds.length + 1); + rowOffsets[0] = 0; + let runningOffset = 0; + for (let rowIndex = 0; rowIndex < rowKinds.length; rowIndex += 1) { + if (rowKinds[rowIndex] === 'header') { + runningOffset += (STICKER_CATEGORY_HEADER_HEIGHT + STICKER_CATEGORY_HEADER_GAP) * remScale; + } else { + runningOffset += stickerRowHeight; + if (rowKinds[rowIndex + 1] === 'header') { + runningOffset += sectionGap; + } + } + rowOffsets[rowIndex + 1] = runningOffset; + } + return rowOffsets; +} + +export function findStickerRowForOffset(rowOffsets: ReadonlyArray, pixel: number): number { + const lastRow = rowOffsets.length - 2; + if (lastRow < 0 || pixel <= 0) { + return 0; + } + if (pixel >= rowOffsets[lastRow + 1]!) { + return lastRow; + } + let low = 0; + let high = lastRow; + while (low < high) { + const mid = (low + high + 1) >> 1; + if (rowOffsets[mid]! <= pixel) { + low = mid; + } else { + high = mid - 1; + } + } + return low; +} + +export function getStickerRowWindow( + rowOffsets: ReadonlyArray, + scrollTop: number, + viewportHeight: number, +): StickerRowWindow { + const totalRows = rowOffsets.length - 1; + if (totalRows <= 0 || !(viewportHeight > 0)) { + return {firstRow: 0, lastRow: -1}; + } + const safeScrollTop = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0; + const leadingVisibleRow = findStickerRowForOffset(rowOffsets, safeScrollTop); + const trailingVisibleRow = findStickerRowForOffset(rowOffsets, safeScrollTop + viewportHeight); + return { + firstRow: Math.max(0, leadingVisibleRow - STICKER_ROW_WINDOW_LEAD_ROWS), + lastRow: Math.min(totalRows - 1, trailingVisibleRow + STICKER_ROW_WINDOW_LEAD_ROWS), + }; +} diff --git a/fluxer_app/src/features/channel/components/sticker_picker/VirtualRow.tsx b/fluxer_app/src/features/channel/components/sticker_picker/VirtualRow.tsx index 63225f3c8..e01dc43f7 100644 --- a/fluxer_app/src/features/channel/components/sticker_picker/VirtualRow.tsx +++ b/fluxer_app/src/features/channel/components/sticker_picker/VirtualRow.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {VirtualRow} from '@app/features/channel/components/sticker_picker/hooks/useVirtualRows'; +import {STICKER_CATEGORY_HEADER_HEIGHT} from '@app/features/channel/components/sticker_picker/StickerPickerConstants'; import styles from '@app/features/channel/components/sticker_picker/VirtualRow.module.css'; import type {Channel} from '@app/features/channel/models/Channel'; import * as StickerPickerCommands from '@app/features/emoji/commands/StickerPickerCommands'; @@ -19,18 +20,13 @@ import {useLingui} from '@lingui/react/macro'; import {CaretDownIcon, ClockIcon, StarIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; -import React, {useEffect, useRef, useState} from 'react'; - -const STICKER_ROW_HEIGHT = 92; -const CATEGORY_HEADER_HEIGHT = 32; -const OVERSCAN_ROWS = 5; +import React from 'react'; interface VirtualRowRendererProps { row: VirtualRow; handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void; handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void; gridColumns?: number; - hoveredSticker: GuildSticker | null; selectedRow: number; selectedColumn: number; stickerRowIndex: number; @@ -65,7 +61,9 @@ const StickerButton: React.FC = React.memo( stickerRowIndex, columnIndex, }) => { - const {shouldAnimate, interactionHandlers} = useStickerAnimation({respectUserSettings: false}); + const {shouldAnimate, interactionHandlers} = useStickerAnimation({ + isAnimated: sticker.animated, + }); return ( @@ -175,7 +173,7 @@ const VirtualRowRendererBase: React.FC = React.memo( type="button" onClick={handleToggleCategory} style={{ - height: remFromPx(CATEGORY_HEADER_HEIGHT), + height: remFromPx(STICKER_CATEGORY_HEADER_HEIGHT), display: 'flex', alignItems: 'center', paddingLeft: '0.75rem', @@ -288,7 +286,6 @@ interface VirtualRowWrapperProps { handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void; handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void; gridColumns?: number; - hoveredSticker: GuildSticker | null; selectedRow: number; selectedColumn: number; stickerRowIndex: number; @@ -303,7 +300,6 @@ export const VirtualRowWrapper: React.FC = observer( handleHover, handleSelect, gridColumns, - hoveredSticker, selectedRow, selectedColumn, stickerRowIndex, @@ -311,63 +307,20 @@ export const VirtualRowWrapper: React.FC = observer( stickerRefs, channel, }) => { - const [isVisible, setIsVisible] = useState(false); - const placeholderRef = useRef(null); - useEffect(() => { - const placeholder = placeholderRef.current; - if (!placeholder) return; - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - setIsVisible(true); - } else { - const rect = entry.boundingClientRect; - const viewportHeight = window.innerHeight; - const overscanDistance = OVERSCAN_ROWS * STICKER_ROW_HEIGHT; - if (rect.bottom < -overscanDistance || rect.top > viewportHeight + overscanDistance) { - setIsVisible(false); - } - } - }); - }, - { - rootMargin: `${OVERSCAN_ROWS * STICKER_ROW_HEIGHT}px 0px`, - threshold: 0, - }, - ); - observer.observe(placeholder); - return () => { - observer.disconnect(); - }; - }, []); - const height = row.type === 'header' ? CATEGORY_HEADER_HEIGHT : STICKER_ROW_HEIGHT; - if (!isVisible) { - return ( -
- ); - } return ( -
- -
+ ); }, );