mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
perf(app): window emoji picker rows by offset instead of observers (#1870)
This commit is contained in:
1 parent
a59f3d0d0c
commit
5834e32aa5
5 files changed
+299
-143
No files matched your search
@@ -236,6 +236,7 @@
|
|||||||
.headerIcon {
|
.headerIcon {
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
|
color: var(--text-primary-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.caretIcon {
|
.caretIcon {
|
||||||
@@ -384,6 +385,11 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emojiGridFixedTrack {
|
||||||
|
grid-auto-flow: column;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
.emojiGridCategory {
|
.emojiGridCategory {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -437,6 +443,15 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emojiImageLoading {
|
||||||
|
color: transparent;
|
||||||
|
object-position: 6.25rem;
|
||||||
|
background-color: var(--background-modifier-accent);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
.spriteEmoji {
|
.spriteEmoji {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||||
import {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAutofocus';
|
import {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAutofocus';
|
||||||
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
|
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
|
||||||
import styles from '@app/features/channel/components/EmojiPicker.module.css';
|
import styles from '@app/features/channel/components/EmojiPicker.module.css';
|
||||||
import {EmojiPickerCategoryList} from '@app/features/channel/components/emoji_picker/EmojiPickerCategoryList';
|
import {EmojiPickerCategoryList} from '@app/features/channel/components/emoji_picker/EmojiPickerCategoryList';
|
||||||
import {getEmojiGridColumns} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants';
|
import {
|
||||||
|
buildEmojiRowOffsets,
|
||||||
|
EMOJI_GRID_TRACK_WIDTH,
|
||||||
|
EMOJI_ROW_HEIGHT,
|
||||||
|
EMOJI_SECTION_GAP,
|
||||||
|
type EmojiRowKind,
|
||||||
|
getEmojiGridColumns,
|
||||||
|
getEmojiRowWindow,
|
||||||
|
} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants';
|
||||||
import {EmojiPickerInspector} from '@app/features/channel/components/emoji_picker/EmojiPickerInspector';
|
import {EmojiPickerInspector} from '@app/features/channel/components/emoji_picker/EmojiPickerInspector';
|
||||||
import {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar';
|
import {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar';
|
||||||
import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories';
|
import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories';
|
||||||
@@ -27,18 +36,28 @@ import {
|
|||||||
shouldShowEmojiPremiumUpsell,
|
shouldShowEmojiPremiumUpsell,
|
||||||
} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
||||||
import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils';
|
import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils';
|
||||||
|
import {getEmojiRenderUrl} from '@app/features/messaging/utils/markdown/EmojiDetector';
|
||||||
import Permission from '@app/features/permissions/state/Permission';
|
import Permission from '@app/features/permissions/state/Permission';
|
||||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||||
import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData';
|
import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData';
|
||||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
import {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils';
|
||||||
import {msg} from '@lingui/core/macro';
|
import {msg} from '@lingui/core/macro';
|
||||||
import {Plural, Trans, useLingui} from '@lingui/react/macro';
|
import {Plural, Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {SmileySadIcon} from '@phosphor-icons/react';
|
import {SmileySadIcon} from '@phosphor-icons/react';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react';
|
import {
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
const NO_EMOJIS_MATCH_YOUR_SEARCH_DESCRIPTOR = msg({
|
const NO_EMOJIS_MATCH_YOUR_SEARCH_DESCRIPTOR = msg({
|
||||||
message: 'No emojis match that search',
|
message: 'No emojis match that search',
|
||||||
@@ -58,14 +77,15 @@ export const EmojiPicker = observer(
|
|||||||
const [selectedColumn, setSelectedColumn] = useState(-1);
|
const [selectedColumn, setSelectedColumn] = useState(-1);
|
||||||
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
|
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
|
||||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||||
const {viewportSize, handleResize} = useScrollerViewport(scrollerRef);
|
const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
|
||||||
const gridColumns = useMemo(() => getEmojiGridColumns(viewportSize.width), [viewportSize.width]);
|
const gridColumns = useMemo(() => getEmojiGridColumns(viewportSize.width), [viewportSize.width]);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||||
const normalizedSearchTerm = useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
|
const normalizedSearchTerm = useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
|
||||||
const {i18n} = useLingui();
|
const {i18n} = useLingui();
|
||||||
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
|
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
|
||||||
const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const rowListRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0});
|
||||||
const [emojiDataVersion, setEmojiDataVersion] = useState(0);
|
const [emojiDataVersion, setEmojiDataVersion] = useState(0);
|
||||||
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
|
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
|
||||||
const skinTone = Emoji.skinTone;
|
const skinTone = Emoji.skinTone;
|
||||||
@@ -80,15 +100,16 @@ export const EmojiPicker = observer(
|
|||||||
const key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-preview`;
|
const key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-preview`;
|
||||||
const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone);
|
const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone);
|
||||||
const displayUrl = emoji.id
|
const displayUrl = emoji.id
|
||||||
? AvatarUtils.getEmojiURL({id: emoji.id, animated: Boolean(emoji.animated) && shouldAnimateEmoji})
|
? getEmojiRenderUrl({
|
||||||
|
id: emoji.id,
|
||||||
|
surrogateUrl: null,
|
||||||
|
isAnimatable: Boolean(emoji.animated),
|
||||||
|
animated: shouldAnimateEmoji,
|
||||||
|
jumbo: false,
|
||||||
|
})
|
||||||
: fallbackDisplayUrl;
|
: fallbackDisplayUrl;
|
||||||
const content = displayUrl ? (
|
const content = displayUrl ? (
|
||||||
<img
|
<img src={displayUrl} alt={emoji.name} data-flx="channel.emoji-picker.render-emoji-preview-item.img" />
|
||||||
src={displayUrl}
|
|
||||||
alt={emoji.name}
|
|
||||||
loading="lazy"
|
|
||||||
data-flx="channel.emoji-picker.render-emoji-preview-item.img"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={premiumStyles.previewEmojiText}
|
className={premiumStyles.previewEmojiText}
|
||||||
@@ -184,17 +205,54 @@ export const EmojiPicker = observer(
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [pickerRows]);
|
}, [pickerRows]);
|
||||||
|
const {emojiRowIndexes, emojiRowStarts, categoryRowIndexes} = useMemo(() => {
|
||||||
|
const indexes = new Array<number>(pickerRows.length);
|
||||||
|
const starts: Array<number> = [];
|
||||||
|
const categories = new Map<string, number>();
|
||||||
|
let emojiRowIndex = 0;
|
||||||
|
for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) {
|
||||||
|
const row = pickerRows[rowIndex];
|
||||||
|
indexes[rowIndex] = emojiRowIndex;
|
||||||
|
if (row.type === 'emoji-row') {
|
||||||
|
starts.push(rowIndex);
|
||||||
|
emojiRowIndex += 1;
|
||||||
|
} else {
|
||||||
|
categories.set(row.category, rowIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {emojiRowIndexes: indexes, emojiRowStarts: starts, categoryRowIndexes: categories};
|
||||||
|
}, [pickerRows]);
|
||||||
|
const zoomLevel = Accessibility.zoomLevel;
|
||||||
|
const remScale = getAppRemScale();
|
||||||
|
const rowOffsets = useMemo(() => {
|
||||||
|
const rowKinds = pickerRows.map((row): EmojiRowKind => row.type);
|
||||||
|
return buildEmojiRowOffsets(rowKinds, {remScale, sectionGap: EMOJI_SECTION_GAP});
|
||||||
|
}, [pickerRows, remScale, 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;
|
||||||
|
setListMetrics((current) =>
|
||||||
|
current.origin === origin && current.viewportHeight === viewportHeight ? current : {origin, viewportHeight},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const rowWindow = useMemo(
|
||||||
|
() => getEmojiRowWindow(rowOffsets, scrollTop - listMetrics.origin, listMetrics.viewportHeight),
|
||||||
|
[rowOffsets, scrollTop, listMetrics],
|
||||||
|
);
|
||||||
const handleCategoryClick = (category: string) => {
|
const handleCategoryClick = (category: string) => {
|
||||||
const element = categoryRefs.current.get(category);
|
const rowIndex = categoryRowIndexes.get(category);
|
||||||
if (element) {
|
if (rowIndex == null) {
|
||||||
scrollerRef.current?.revealElement({node: element, preferStartEdge: true});
|
return;
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleHover = (emoji: FlatEmoji | null, row?: number, column?: number) => {
|
|
||||||
setHoveredEmoji(emoji);
|
|
||||||
if (emoji && row !== undefined && column !== undefined) {
|
|
||||||
handleSelectionChange(row, column, false);
|
|
||||||
}
|
}
|
||||||
|
scrollerRef.current?.scrollTo({to: listMetrics.origin + rowOffsets[rowIndex]!});
|
||||||
};
|
};
|
||||||
const handleEmojiSelect = useCallback(
|
const handleEmojiSelect = useCallback(
|
||||||
(emoji: FlatEmoji, shiftKey?: boolean) => {
|
(emoji: FlatEmoji, shiftKey?: boolean) => {
|
||||||
@@ -229,11 +287,31 @@ export const EmojiPicker = observer(
|
|||||||
},
|
},
|
||||||
[pickerRows],
|
[pickerRows],
|
||||||
);
|
);
|
||||||
|
const handleHover = useCallback(
|
||||||
|
(emoji: FlatEmoji | null, row?: number, column?: number) => {
|
||||||
|
setHoveredEmoji(emoji);
|
||||||
|
if (emoji && row !== undefined && column !== undefined) {
|
||||||
|
handleSelectionChange(row, column, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSelectionChange],
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (renderedEmojis.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredEmoji) {
|
if (renderedEmojis.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredEmoji) {
|
||||||
handleSelectionChange(0, 0, false);
|
handleSelectionChange(0, 0, false);
|
||||||
}
|
}
|
||||||
}, [renderedEmojis, selectedRow, selectedColumn, hoveredEmoji, handleSelectionChange]);
|
}, [renderedEmojis, selectedRow, selectedColumn, hoveredEmoji, handleSelectionChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldScrollOnSelection || selectedRow < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rowIndex = emojiRowStarts[selectedRow];
|
||||||
|
if (rowIndex == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = listMetrics.origin + rowOffsets[rowIndex]!;
|
||||||
|
scrollerRef.current?.revealRange({start, end: start + EMOJI_ROW_HEIGHT * remScale});
|
||||||
|
}, [shouldScrollOnSelection, selectedRow, emojiRowStarts, rowOffsets, listMetrics.origin, remScale]);
|
||||||
const handleSelectEmoji = useCallback(
|
const handleSelectEmoji = useCallback(
|
||||||
(row: number | null, column: number | null, event?: React.KeyboardEvent) => {
|
(row: number | null, column: number | null, event?: React.KeyboardEvent) => {
|
||||||
if (row === null || column === null) {
|
if (row === null || column === null) {
|
||||||
@@ -281,8 +359,8 @@ export const EmojiPicker = observer(
|
|||||||
className={`${styles.list} ${styles.listWrapper}`}
|
className={`${styles.list} ${styles.listWrapper}`}
|
||||||
fade={false}
|
fade={false}
|
||||||
key="emoji_picker-scroller"
|
key="emoji_picker-scroller"
|
||||||
|
onScroll={handleScroll}
|
||||||
onResize={handleResize}
|
onResize={handleResize}
|
||||||
data-emoji-picker-scroll-root="true"
|
|
||||||
data-flx="channel.emoji-picker.list"
|
data-flx="channel.emoji-picker.list"
|
||||||
>
|
>
|
||||||
{showPremiumUpsell && (
|
{showPremiumUpsell && (
|
||||||
@@ -294,22 +372,22 @@ export const EmojiPicker = observer(
|
|||||||
data-flx="channel.emoji-picker.premium-upsell-banner"
|
data-flx="channel.emoji-picker.premium-upsell-banner"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{pickerRows.map((row, index) => {
|
<div
|
||||||
const emojiRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'emoji-row').length;
|
ref={rowListRef}
|
||||||
const needsSpacingAfter = row.type === 'emoji-row' && pickerRows[index + 1]?.type === 'header';
|
style={{position: 'relative', flexShrink: 0, height: `${contentHeight}px`}}
|
||||||
|
data-flx="channel.emoji-picker.row-list"
|
||||||
|
>
|
||||||
|
{pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => {
|
||||||
|
const rowIndex = rowWindow.firstRow + windowIndex;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${row.type}-${row.index}`}
|
key={`${row.type}-${row.index}`}
|
||||||
ref={
|
style={{
|
||||||
row.type === 'header'
|
position: 'absolute',
|
||||||
? (el) => {
|
left: 0,
|
||||||
if (el && 'category' in row) {
|
right: 0,
|
||||||
categoryRefs.current.set(row.category, el);
|
transform: `translateY(${rowOffsets[rowIndex]!}px)`,
|
||||||
}
|
}}
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
style={row.type === 'emoji-row' && needsSpacingAfter ? {marginBottom: '12px'} : undefined}
|
|
||||||
data-flx="channel.emoji-picker.div"
|
data-flx="channel.emoji-picker.div"
|
||||||
>
|
>
|
||||||
<VirtualizedRow
|
<VirtualizedRow
|
||||||
@@ -320,10 +398,10 @@ export const EmojiPicker = observer(
|
|||||||
channel={channel}
|
channel={channel}
|
||||||
allowAnimation={shouldAnimateEmoji}
|
allowAnimation={shouldAnimateEmoji}
|
||||||
gridColumns={gridColumns}
|
gridColumns={gridColumns}
|
||||||
hoveredEmoji={hoveredEmoji}
|
cellTrackWidth={EMOJI_GRID_TRACK_WIDTH}
|
||||||
selectedRow={selectedRow}
|
selectedRow={selectedRow}
|
||||||
selectedColumn={selectedColumn}
|
selectedColumn={selectedColumn}
|
||||||
emojiRowIndex={emojiRowIndex}
|
emojiRowIndex={emojiRowIndexes[rowIndex]!}
|
||||||
shouldScrollOnSelection={shouldScrollOnSelection}
|
shouldScrollOnSelection={shouldScrollOnSelection}
|
||||||
emojiRefs={emojiRefs}
|
emojiRefs={emojiRefs}
|
||||||
data-flx="channel.emoji-picker.virtualized-row"
|
data-flx="channel.emoji-picker.virtualized-row"
|
||||||
@@ -331,6 +409,7 @@ export const EmojiPicker = observer(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</Scroller>
|
</Scroller>
|
||||||
{renderedEmojis.length === 0 && (
|
{renderedEmojis.length === 0 && (
|
||||||
<div className={styles.emptyState} data-flx="channel.emoji-picker.empty-state">
|
<div className={styles.emptyState} data-flx="channel.emoji-picker.empty-state">
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstant
|
|||||||
import {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAutofocus';
|
import {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAutofocus';
|
||||||
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
|
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
|
||||||
import {EmojiPickerCategoryList} from '@app/features/channel/components/emoji_picker/EmojiPickerCategoryList';
|
import {EmojiPickerCategoryList} from '@app/features/channel/components/emoji_picker/EmojiPickerCategoryList';
|
||||||
|
import {
|
||||||
|
buildEmojiRowOffsets,
|
||||||
|
type EmojiRowKind,
|
||||||
|
getEmojiRowWindow,
|
||||||
|
} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants';
|
||||||
import {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar';
|
import {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar';
|
||||||
import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories';
|
import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories';
|
||||||
import {useVirtualRows} from '@app/features/channel/components/emoji_picker/hooks/useVirtualRows';
|
import {useVirtualRows} from '@app/features/channel/components/emoji_picker/hooks/useVirtualRows';
|
||||||
@@ -26,16 +31,17 @@ import {
|
|||||||
shouldShowEmojiPremiumUpsell,
|
shouldShowEmojiPremiumUpsell,
|
||||||
} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
||||||
import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils';
|
import {getEmojiDisplayDataWithSkinTone} from '@app/features/expressions/utils/SkinToneUtils';
|
||||||
|
import {getEmojiRenderUrl} from '@app/features/messaging/utils/markdown/EmojiDetector';
|
||||||
import Permission from '@app/features/permissions/state/Permission';
|
import Permission from '@app/features/permissions/state/Permission';
|
||||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||||
import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData';
|
import {usePremiumUpsellData} from '@app/features/premium/hooks/usePremiumUpsellData';
|
||||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
import {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils';
|
||||||
import {Plural, Trans, useLingui} from '@lingui/react/macro';
|
import {Plural, Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react';
|
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react';
|
||||||
|
|
||||||
function getVisibleViewportWidth(): number {
|
function getVisibleViewportWidth(): number {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -67,9 +73,10 @@ export const MobileEmojiPicker = observer(
|
|||||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||||
const {viewportSize, handleResize} = useScrollerViewport(scrollerRef);
|
const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
|
||||||
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
|
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
|
||||||
const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const rowListRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0});
|
||||||
const [emojiDataVersion, setEmojiDataVersion] = useState(0);
|
const [emojiDataVersion, setEmojiDataVersion] = useState(0);
|
||||||
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
|
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
|
||||||
const getEmojiAvailability = useCallback(
|
const getEmojiAvailability = useCallback(
|
||||||
@@ -119,7 +126,13 @@ export const MobileEmojiPicker = observer(
|
|||||||
const key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-mobile`;
|
const key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-mobile`;
|
||||||
const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone);
|
const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone);
|
||||||
const displayUrl = emoji.id
|
const displayUrl = emoji.id
|
||||||
? AvatarUtils.getEmojiURL({id: emoji.id, animated: Boolean(emoji.animated) && shouldAnimateEmoji})
|
? getEmojiRenderUrl({
|
||||||
|
id: emoji.id,
|
||||||
|
surrogateUrl: null,
|
||||||
|
isAnimatable: Boolean(emoji.animated),
|
||||||
|
animated: shouldAnimateEmoji,
|
||||||
|
jumbo: false,
|
||||||
|
})
|
||||||
: fallbackDisplayUrl;
|
: fallbackDisplayUrl;
|
||||||
let content: React.ReactNode;
|
let content: React.ReactNode;
|
||||||
if (displayUrl) {
|
if (displayUrl) {
|
||||||
@@ -127,7 +140,6 @@ export const MobileEmojiPicker = observer(
|
|||||||
<img
|
<img
|
||||||
src={displayUrl}
|
src={displayUrl}
|
||||||
alt={emoji.name}
|
alt={emoji.name}
|
||||||
loading="lazy"
|
|
||||||
data-flx="channel.mobile-emoji-picker.render-emoji-preview-item.img"
|
data-flx="channel.mobile-emoji-picker.render-emoji-preview-item.img"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -220,15 +232,50 @@ export const MobileEmojiPicker = observer(
|
|||||||
shouldShowEmojiPremiumUpsell(channel) &&
|
shouldShowEmojiPremiumUpsell(channel) &&
|
||||||
!normalizedSearchTerm &&
|
!normalizedSearchTerm &&
|
||||||
lockedEmojiCount > 0;
|
lockedEmojiCount > 0;
|
||||||
const handleCategoryClick = (category: string) => {
|
const categoryRowIndexes = useMemo(() => {
|
||||||
const element = categoryRefs.current.get(category);
|
const categories = new Map<string, number>();
|
||||||
if (element) {
|
for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) {
|
||||||
scrollerRef.current?.revealElement({node: element, preferStartEdge: true});
|
const row = pickerRows[rowIndex];
|
||||||
|
if (row.type === 'header') {
|
||||||
|
categories.set(row.category, rowIndex);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return categories;
|
||||||
|
}, [pickerRows]);
|
||||||
|
const remScale = getAppRemScale();
|
||||||
|
const rowOffsets = useMemo(() => {
|
||||||
|
const rowKinds = pickerRows.map((row): EmojiRowKind => row.type);
|
||||||
|
return buildEmojiRowOffsets(rowKinds, {remScale, sectionGap: 0});
|
||||||
|
}, [pickerRows, remScale, 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;
|
||||||
|
setListMetrics((current) =>
|
||||||
|
current.origin === origin && current.viewportHeight === viewportHeight ? current : {origin, viewportHeight},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const rowWindow = useMemo(
|
||||||
|
() => getEmojiRowWindow(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 = (emoji: FlatEmoji | null) => {
|
const handleHover = useCallback((emoji: FlatEmoji | null) => {
|
||||||
setHoveredEmoji(emoji);
|
setHoveredEmoji(emoji);
|
||||||
};
|
}, []);
|
||||||
const searchBar = !hideSearchBar ? (
|
const searchBar = !hideSearchBar ? (
|
||||||
<EmojiPickerSearchBar
|
<EmojiPickerSearchBar
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
@@ -256,7 +303,7 @@ export const MobileEmojiPicker = observer(
|
|||||||
ref={scrollerRef}
|
ref={scrollerRef}
|
||||||
className={`${mobileStyles.list} ${mobileStyles.listWrapper}`}
|
className={`${mobileStyles.list} ${mobileStyles.listWrapper}`}
|
||||||
key="mobile-emoji_picker-scroller"
|
key="mobile-emoji_picker-scroller"
|
||||||
data-emoji-picker-scroll-root="true"
|
onScroll={handleScroll}
|
||||||
onResize={handleResize}
|
onResize={handleResize}
|
||||||
data-flx="channel.mobile-emoji-picker.scroller"
|
data-flx="channel.mobile-emoji-picker.scroller"
|
||||||
>
|
>
|
||||||
@@ -269,18 +316,20 @@ export const MobileEmojiPicker = observer(
|
|||||||
data-flx="channel.mobile-emoji-picker.premium-upsell-banner"
|
data-flx="channel.mobile-emoji-picker.premium-upsell-banner"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{pickerRows.map((row) => (
|
<div
|
||||||
|
ref={rowListRef}
|
||||||
|
style={{position: 'relative', flexShrink: 0, height: `${contentHeight}px`}}
|
||||||
|
data-flx="channel.mobile-emoji-picker.row-list"
|
||||||
|
>
|
||||||
|
{pickerRows.slice(rowWindow.firstRow, rowWindow.lastRow + 1).map((row, windowIndex) => (
|
||||||
<div
|
<div
|
||||||
key={`${row.type}-${row.index}`}
|
key={`${row.type}-${row.index}`}
|
||||||
ref={
|
style={{
|
||||||
row.type === 'header'
|
position: 'absolute',
|
||||||
? (el) => {
|
left: 0,
|
||||||
if (el && 'category' in row) {
|
right: 0,
|
||||||
categoryRefs.current.set(row.category, el);
|
transform: `translateY(${rowOffsets[rowWindow.firstRow + windowIndex]!}px)`,
|
||||||
}
|
}}
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
data-flx="channel.mobile-emoji-picker.div--4"
|
data-flx="channel.mobile-emoji-picker.div--4"
|
||||||
>
|
>
|
||||||
<VirtualizedRow
|
<VirtualizedRow
|
||||||
@@ -292,7 +341,6 @@ export const MobileEmojiPicker = observer(
|
|||||||
allowAnimation={shouldAnimateEmoji}
|
allowAnimation={shouldAnimateEmoji}
|
||||||
gridColumns={gridColumns}
|
gridColumns={gridColumns}
|
||||||
gridWidth={gridWidth}
|
gridWidth={gridWidth}
|
||||||
hoveredEmoji={hoveredEmoji}
|
|
||||||
selectedRow={-1}
|
selectedRow={-1}
|
||||||
selectedColumn={-1}
|
selectedColumn={-1}
|
||||||
emojiRowIndex={0}
|
emojiRowIndex={0}
|
||||||
@@ -301,6 +349,7 @@ export const MobileEmojiPicker = observer(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</Scroller>
|
</Scroller>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {convertToCodePoints} from '@app/features/expressions/utils/EmojiCodepointUtils';
|
import {convertToCodePoints} from '@app/features/expressions/utils/EmojiCodepointUtils';
|
||||||
import {REM_BASE_PX, remFromPx} from '@app/features/theme/layout/RemFromPx';
|
import {getRemScaleForDocument, remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||||
import sprite1f3fb2x from '@app/media/images/emoji-sprites/[email protected]';
|
import sprite1f3fb2x from '@app/media/images/emoji-sprites/[email protected]';
|
||||||
import sprite1f3fb1x from '@app/media/images/emoji-sprites/spritesheet-1f3fb.png';
|
import sprite1f3fb1x from '@app/media/images/emoji-sprites/spritesheet-1f3fb.png';
|
||||||
import sprite1f3fc2x from '@app/media/images/emoji-sprites/[email protected]';
|
import sprite1f3fc2x from '@app/media/images/emoji-sprites/[email protected]';
|
||||||
@@ -23,28 +23,90 @@ export const EMOJI_PICKER_CUSTOM_EMOJI_SIZE = 48;
|
|||||||
export const CATEGORY_HEADER_HEIGHT = 32;
|
export const CATEGORY_HEADER_HEIGHT = 32;
|
||||||
export const EMOJIS_PER_ROW = 9;
|
export const EMOJIS_PER_ROW = 9;
|
||||||
const EMOJIS_PER_ROW_MIN = 5;
|
const EMOJIS_PER_ROW_MIN = 5;
|
||||||
export const OVERSCAN_ROWS = 5;
|
export const EMOJI_SECTION_GAP = 12;
|
||||||
|
const CATEGORY_HEADER_GAP = 8;
|
||||||
|
|
||||||
const EMOJI_GRID_INLINE_PADDING = 16;
|
|
||||||
const EMOJI_GRID_MIN_CELL_WIDTH = EMOJI_ROW_HEIGHT;
|
const EMOJI_GRID_MIN_CELL_WIDTH = EMOJI_ROW_HEIGHT;
|
||||||
|
export const EMOJI_GRID_TRACK_WIDTH = EMOJI_GRID_MIN_CELL_WIDTH;
|
||||||
function getRemScale(): number {
|
export const EMOJI_GRID_SCROLLER_PADDING_WIDTH = 8;
|
||||||
if (typeof document === 'undefined') return 1;
|
|
||||||
const rootFontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
||||||
if (Number.isFinite(rootFontSize) && rootFontSize > 0) {
|
|
||||||
return rootFontSize / REM_BASE_PX;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEmojiGridColumns(containerWidth: number): number {
|
export function getEmojiGridColumns(containerWidth: number): number {
|
||||||
if (containerWidth <= 0) return EMOJIS_PER_ROW;
|
if (containerWidth <= 0) return EMOJIS_PER_ROW;
|
||||||
const remScale = getRemScale();
|
const remScale = getRemScaleForDocument(typeof document === 'undefined' ? null : document);
|
||||||
const availableWidth = Math.max(0, containerWidth - EMOJI_GRID_INLINE_PADDING * remScale);
|
const columns = Math.floor(containerWidth / (EMOJI_GRID_MIN_CELL_WIDTH * remScale));
|
||||||
const columns = Math.floor(availableWidth / (EMOJI_GRID_MIN_CELL_WIDTH * remScale));
|
|
||||||
return Math.max(EMOJIS_PER_ROW_MIN, columns);
|
return Math.max(EMOJIS_PER_ROW_MIN, columns);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EmojiRowKind = 'header' | 'emoji-row';
|
||||||
|
|
||||||
|
export interface EmojiRowMetrics {
|
||||||
|
remScale: number;
|
||||||
|
sectionGap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmojiRowWindow {
|
||||||
|
firstRow: number;
|
||||||
|
lastRow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEmojiRowOffsets(
|
||||||
|
rowKinds: ReadonlyArray<EmojiRowKind>,
|
||||||
|
{remScale, sectionGap}: EmojiRowMetrics,
|
||||||
|
): Array<number> {
|
||||||
|
const rowOffsets = new Array<number>(rowKinds.length + 1);
|
||||||
|
rowOffsets[0] = 0;
|
||||||
|
let runningOffset = 0;
|
||||||
|
for (let rowIndex = 0; rowIndex < rowKinds.length; rowIndex += 1) {
|
||||||
|
if (rowKinds[rowIndex] === 'header') {
|
||||||
|
runningOffset += (CATEGORY_HEADER_HEIGHT + CATEGORY_HEADER_GAP) * remScale;
|
||||||
|
} else {
|
||||||
|
runningOffset += EMOJI_ROW_HEIGHT * remScale;
|
||||||
|
if (rowKinds[rowIndex + 1] === 'header') {
|
||||||
|
runningOffset += sectionGap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rowOffsets[rowIndex + 1] = runningOffset;
|
||||||
|
}
|
||||||
|
return rowOffsets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmojiRowForOffset(rowOffsets: ReadonlyArray<number>, 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 getEmojiRowWindow(
|
||||||
|
rowOffsets: ReadonlyArray<number>,
|
||||||
|
scrollTop: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
): EmojiRowWindow {
|
||||||
|
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;
|
||||||
|
return {
|
||||||
|
firstRow: findEmojiRowForOffset(rowOffsets, safeScrollTop),
|
||||||
|
lastRow: findEmojiRowForOffset(rowOffsets, safeScrollTop + viewportHeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const getEmojiSpriteSheetLayout = (index: number, perRow: number, rows: number): CSSProperties => {
|
export const getEmojiSpriteSheetLayout = (index: number, perRow: number, rows: number): CSSProperties => {
|
||||||
const column = index % perRow;
|
const column = index % perRow;
|
||||||
const row = Math.floor(index / perRow);
|
const row = Math.floor(index / perRow);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import styles from '@app/features/channel/components/EmojiPicker.module.css';
|
|||||||
import {
|
import {
|
||||||
CATEGORY_HEADER_HEIGHT,
|
CATEGORY_HEADER_HEIGHT,
|
||||||
EMOJI_ROW_HEIGHT,
|
EMOJI_ROW_HEIGHT,
|
||||||
OVERSCAN_ROWS,
|
|
||||||
} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants';
|
} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants';
|
||||||
import {EmojiRenderer} from '@app/features/channel/components/emoji_picker/EmojiRenderer';
|
import {EmojiRenderer} from '@app/features/channel/components/emoji_picker/EmojiRenderer';
|
||||||
import type {Channel} from '@app/features/channel/models/Channel';
|
import type {Channel} from '@app/features/channel/models/Channel';
|
||||||
@@ -13,11 +12,10 @@ import EmojiPicker from '@app/features/emoji/state/EmojiPicker';
|
|||||||
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||||
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
|
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
|
||||||
import Guilds from '@app/features/guild/state/Guilds';
|
import Guilds from '@app/features/guild/state/Guilds';
|
||||||
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
|
|
||||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||||
import {CaretDownIcon, ClockIcon, StarIcon} from '@phosphor-icons/react';
|
import {CaretDownIcon, ClockIcon, StarIcon} from '@phosphor-icons/react';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import React, {useEffect, useRef, useState} from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export type VirtualRow =
|
export type VirtualRow =
|
||||||
| {type: 'header'; category: string; name: string; guildId?: string; index: number}
|
| {type: 'header'; category: string; name: string; guildId?: string; index: number}
|
||||||
@@ -32,9 +30,8 @@ interface VirtualRowRendererProps {
|
|||||||
shouldAnimate: boolean;
|
shouldAnimate: boolean;
|
||||||
gridColumns?: number;
|
gridColumns?: number;
|
||||||
gridWidth?: number;
|
gridWidth?: number;
|
||||||
hoveredEmoji: FlatEmoji | null;
|
cellTrackWidth?: number;
|
||||||
selectedRow: number;
|
selectedColumnInRow: number;
|
||||||
selectedColumn: number;
|
|
||||||
emojiRowIndex: number;
|
emojiRowIndex: number;
|
||||||
shouldScrollOnSelection?: boolean;
|
shouldScrollOnSelection?: boolean;
|
||||||
emojiRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>;
|
emojiRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>;
|
||||||
@@ -50,8 +47,8 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
|
|||||||
shouldAnimate,
|
shouldAnimate,
|
||||||
gridColumns = 9,
|
gridColumns = 9,
|
||||||
gridWidth,
|
gridWidth,
|
||||||
selectedRow,
|
cellTrackWidth,
|
||||||
selectedColumn,
|
selectedColumnInRow,
|
||||||
emojiRowIndex,
|
emojiRowIndex,
|
||||||
shouldScrollOnSelection = false,
|
shouldScrollOnSelection = false,
|
||||||
emojiRefs,
|
emojiRefs,
|
||||||
@@ -98,7 +95,6 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleToggleCategory}
|
onClick={handleToggleCategory}
|
||||||
className={styles.categoryTitle}
|
|
||||||
style={{
|
style={{
|
||||||
height: remFromPx(CATEGORY_HEADER_HEIGHT),
|
height: remFromPx(CATEGORY_HEADER_HEIGHT),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -146,6 +142,7 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const hasFixedTrack = cellTrackWidth != null && cellTrackWidth > 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -155,20 +152,22 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
|
|||||||
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.div--2"
|
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.div--2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={styles.emojiGrid}
|
className={hasFixedTrack ? `${styles.emojiGrid} ${styles.emojiGridFixedTrack}` : styles.emojiGrid}
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
gridTemplateColumns: hasFixedTrack
|
||||||
|
? `repeat(auto-fill, ${remFromPx(cellTrackWidth)})`
|
||||||
|
: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||||
marginInline: gridWidth ? 'auto' : undefined,
|
marginInline: gridWidth ? 'auto' : undefined,
|
||||||
width: gridWidth ? `${gridWidth}px` : undefined,
|
width: gridWidth ? `${gridWidth}px` : undefined,
|
||||||
}}
|
}}
|
||||||
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.emoji-grid"
|
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.emoji-grid"
|
||||||
>
|
>
|
||||||
{row.emojis.map((emoji, colIndex) => {
|
{row.emojis.map((emoji, colIndex) => {
|
||||||
const isSelected = emojiRowIndex === selectedRow && colIndex === selectedColumn;
|
const isSelected = colIndex === selectedColumnInRow;
|
||||||
const shouldHighlight = isSelected;
|
const shouldHighlight = isSelected;
|
||||||
return (
|
return (
|
||||||
<EmojiRenderer
|
<EmojiRenderer
|
||||||
key={emoji.name}
|
key={emoji.id ?? emoji.uniqueName}
|
||||||
emoji={emoji}
|
emoji={emoji}
|
||||||
handleHover={(e) => handleHover(e, emojiRowIndex, colIndex)}
|
handleHover={(e) => handleHover(e, emojiRowIndex, colIndex)}
|
||||||
handleSelect={handleSelect}
|
handleSelect={handleSelect}
|
||||||
@@ -204,7 +203,7 @@ interface VirtualizedRowProps {
|
|||||||
allowAnimation: boolean;
|
allowAnimation: boolean;
|
||||||
gridColumns?: number;
|
gridColumns?: number;
|
||||||
gridWidth?: number;
|
gridWidth?: number;
|
||||||
hoveredEmoji: FlatEmoji | null;
|
cellTrackWidth?: number;
|
||||||
selectedRow: number;
|
selectedRow: number;
|
||||||
selectedColumn: number;
|
selectedColumn: number;
|
||||||
emojiRowIndex: number;
|
emojiRowIndex: number;
|
||||||
@@ -222,79 +221,31 @@ export const VirtualizedRow: React.FC<VirtualizedRowProps> = observer(
|
|||||||
allowAnimation,
|
allowAnimation,
|
||||||
gridColumns,
|
gridColumns,
|
||||||
gridWidth,
|
gridWidth,
|
||||||
hoveredEmoji,
|
cellTrackWidth,
|
||||||
selectedRow,
|
selectedRow,
|
||||||
selectedColumn,
|
selectedColumn,
|
||||||
emojiRowIndex,
|
emojiRowIndex,
|
||||||
shouldScrollOnSelection = false,
|
shouldScrollOnSelection = false,
|
||||||
emojiRefs,
|
emojiRefs,
|
||||||
}) => {
|
}) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const selectedColumnInRow = emojiRowIndex === selectedRow ? selectedColumn : -1;
|
||||||
const [isInViewport, setIsInViewport] = useState(false);
|
|
||||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const placeholder = placeholderRef.current;
|
|
||||||
if (!placeholder) return;
|
|
||||||
const root = placeholder.closest('[data-emoji-picker-scroll-root]') as Element | null;
|
|
||||||
const overscanDistance = OVERSCAN_ROWS * EMOJI_ROW_HEIGHT;
|
|
||||||
const unobserveVisibility = observeIntersection(
|
|
||||||
placeholder,
|
|
||||||
(entry) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setIsVisible(true);
|
|
||||||
} else {
|
|
||||||
const rect = entry.boundingClientRect;
|
|
||||||
const rootTop = entry.rootBounds?.top ?? 0;
|
|
||||||
const rootBottom = entry.rootBounds?.bottom ?? window.innerHeight;
|
|
||||||
if (rect.bottom < rootTop - overscanDistance || rect.top > rootBottom + overscanDistance) {
|
|
||||||
setIsVisible(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{root, rootMargin: `${OVERSCAN_ROWS * EMOJI_ROW_HEIGHT}px 0px`, threshold: 0},
|
|
||||||
);
|
|
||||||
const unobserveAnimation = observeIntersection(
|
|
||||||
placeholder,
|
|
||||||
(entry) => {
|
|
||||||
setIsInViewport(entry.isIntersecting);
|
|
||||||
},
|
|
||||||
{root, rootMargin: '0px', threshold: 0},
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
unobserveVisibility();
|
|
||||||
unobserveAnimation();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const height = row.type === 'header' ? CATEGORY_HEADER_HEIGHT : EMOJI_ROW_HEIGHT;
|
|
||||||
if (!isVisible) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
ref={placeholderRef}
|
|
||||||
style={{height: remFromPx(height)}}
|
|
||||||
data-flx="channel.emoji-picker.virtual-row.virtualized-row.div"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div ref={placeholderRef} data-flx="channel.emoji-picker.virtual-row.virtualized-row.div--2">
|
|
||||||
<VirtualRowRenderer
|
<VirtualRowRenderer
|
||||||
row={row}
|
row={row}
|
||||||
handleHover={handleHover}
|
handleHover={handleHover}
|
||||||
handleSelect={handleSelect}
|
handleSelect={handleSelect}
|
||||||
skinTone={skinTone}
|
skinTone={skinTone}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
shouldAnimate={allowAnimation && isInViewport}
|
shouldAnimate={allowAnimation}
|
||||||
gridColumns={gridColumns}
|
gridColumns={gridColumns}
|
||||||
gridWidth={gridWidth}
|
gridWidth={gridWidth}
|
||||||
hoveredEmoji={hoveredEmoji}
|
cellTrackWidth={cellTrackWidth}
|
||||||
selectedRow={selectedRow}
|
selectedColumnInRow={selectedColumnInRow}
|
||||||
selectedColumn={selectedColumn}
|
|
||||||
emojiRowIndex={emojiRowIndex}
|
emojiRowIndex={emojiRowIndex}
|
||||||
shouldScrollOnSelection={shouldScrollOnSelection}
|
shouldScrollOnSelection={shouldScrollOnSelection}
|
||||||
emojiRefs={emojiRefs}
|
emojiRefs={emojiRefs}
|
||||||
data-flx="channel.emoji-picker.virtual-row.virtualized-row.virtual-row-renderer"
|
data-flx="channel.emoji-picker.virtual-row.virtualized-row.virtual-row-renderer"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Reference in new issue
Block a user