mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05: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 {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.caretIcon {
|
||||
@@ -384,6 +385,11 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.emojiGridFixedTrack {
|
||||
grid-auto-flow: column;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.emojiGridCategory {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -437,6 +443,15 @@
|
||||
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 {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
// 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 {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
|
||||
import styles from '@app/features/channel/components/EmojiPicker.module.css';
|
||||
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 {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar';
|
||||
import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories';
|
||||
@@ -27,18 +36,28 @@ import {
|
||||
shouldShowEmojiPremiumUpsell,
|
||||
} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
||||
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 {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 * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro';
|
||||
import {SmileySadIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
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({
|
||||
message: 'No emojis match that search',
|
||||
@@ -58,14 +77,15 @@ export const EmojiPicker = observer(
|
||||
const [selectedColumn, setSelectedColumn] = useState(-1);
|
||||
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
|
||||
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 searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const normalizedSearchTerm = useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
|
||||
const {i18n} = useLingui();
|
||||
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 permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
|
||||
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 {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone);
|
||||
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;
|
||||
const content = displayUrl ? (
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt={emoji.name}
|
||||
loading="lazy"
|
||||
data-flx="channel.emoji-picker.render-emoji-preview-item.img"
|
||||
/>
|
||||
<img src={displayUrl} alt={emoji.name} data-flx="channel.emoji-picker.render-emoji-preview-item.img" />
|
||||
) : (
|
||||
<span
|
||||
className={premiumStyles.previewEmojiText}
|
||||
@@ -184,17 +205,54 @@ export const EmojiPicker = observer(
|
||||
}
|
||||
return result;
|
||||
}, [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 element = categoryRefs.current.get(category);
|
||||
if (element) {
|
||||
scrollerRef.current?.revealElement({node: element, preferStartEdge: true});
|
||||
}
|
||||
};
|
||||
const handleHover = (emoji: FlatEmoji | null, row?: number, column?: number) => {
|
||||
setHoveredEmoji(emoji);
|
||||
if (emoji && 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 handleEmojiSelect = useCallback(
|
||||
(emoji: FlatEmoji, shiftKey?: boolean) => {
|
||||
@@ -229,11 +287,31 @@ export const EmojiPicker = observer(
|
||||
},
|
||||
[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(() => {
|
||||
if (renderedEmojis.length > 0 && selectedRow === 0 && selectedColumn === 0 && !hoveredEmoji) {
|
||||
handleSelectionChange(0, 0, false);
|
||||
}
|
||||
}, [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(
|
||||
(row: number | null, column: number | null, event?: React.KeyboardEvent) => {
|
||||
if (row === null || column === null) {
|
||||
@@ -281,8 +359,8 @@ export const EmojiPicker = observer(
|
||||
className={`${styles.list} ${styles.listWrapper}`}
|
||||
fade={false}
|
||||
key="emoji_picker-scroller"
|
||||
onScroll={handleScroll}
|
||||
onResize={handleResize}
|
||||
data-emoji-picker-scroll-root="true"
|
||||
data-flx="channel.emoji-picker.list"
|
||||
>
|
||||
{showPremiumUpsell && (
|
||||
@@ -294,22 +372,22 @@ export const EmojiPicker = observer(
|
||||
data-flx="channel.emoji-picker.premium-upsell-banner"
|
||||
/>
|
||||
)}
|
||||
{pickerRows.map((row, index) => {
|
||||
const emojiRowIndex = pickerRows.slice(0, index).filter((r) => r.type === 'emoji-row').length;
|
||||
const needsSpacingAfter = row.type === 'emoji-row' && pickerRows[index + 1]?.type === 'header';
|
||||
<div
|
||||
ref={rowListRef}
|
||||
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 (
|
||||
<div
|
||||
key={`${row.type}-${row.index}`}
|
||||
ref={
|
||||
row.type === 'header'
|
||||
? (el) => {
|
||||
if (el && 'category' in row) {
|
||||
categoryRefs.current.set(row.category, el);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={row.type === 'emoji-row' && needsSpacingAfter ? {marginBottom: '12px'} : undefined}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: `translateY(${rowOffsets[rowIndex]!}px)`,
|
||||
}}
|
||||
data-flx="channel.emoji-picker.div"
|
||||
>
|
||||
<VirtualizedRow
|
||||
@@ -320,10 +398,10 @@ export const EmojiPicker = observer(
|
||||
channel={channel}
|
||||
allowAnimation={shouldAnimateEmoji}
|
||||
gridColumns={gridColumns}
|
||||
hoveredEmoji={hoveredEmoji}
|
||||
cellTrackWidth={EMOJI_GRID_TRACK_WIDTH}
|
||||
selectedRow={selectedRow}
|
||||
selectedColumn={selectedColumn}
|
||||
emojiRowIndex={emojiRowIndex}
|
||||
emojiRowIndex={emojiRowIndexes[rowIndex]!}
|
||||
shouldScrollOnSelection={shouldScrollOnSelection}
|
||||
emojiRefs={emojiRefs}
|
||||
data-flx="channel.emoji-picker.virtualized-row"
|
||||
@@ -331,6 +409,7 @@ export const EmojiPicker = observer(
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
{renderedEmojis.length === 0 && (
|
||||
<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 {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
|
||||
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 {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories';
|
||||
import {useVirtualRows} from '@app/features/channel/components/emoji_picker/hooks/useVirtualRows';
|
||||
@@ -26,16 +31,17 @@ import {
|
||||
shouldShowEmojiPremiumUpsell,
|
||||
} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
||||
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 {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 * 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 {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';
|
||||
|
||||
function getVisibleViewportWidth(): number {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -67,9 +73,10 @@ export const MobileEmojiPicker = observer(
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
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 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 permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
|
||||
const getEmojiAvailability = useCallback(
|
||||
@@ -119,7 +126,13 @@ export const MobileEmojiPicker = observer(
|
||||
const key = emoji.id ?? emoji.uniqueName ?? emoji.name ?? `${emoji.guildId ?? 'unicode'}-mobile`;
|
||||
const {url: fallbackDisplayUrl} = getEmojiDisplayDataWithSkinTone(emoji, skinTone);
|
||||
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;
|
||||
let content: React.ReactNode;
|
||||
if (displayUrl) {
|
||||
@@ -127,7 +140,6 @@ export const MobileEmojiPicker = observer(
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt={emoji.name}
|
||||
loading="lazy"
|
||||
data-flx="channel.mobile-emoji-picker.render-emoji-preview-item.img"
|
||||
/>
|
||||
);
|
||||
@@ -220,15 +232,50 @@ export const MobileEmojiPicker = observer(
|
||||
shouldShowEmojiPremiumUpsell(channel) &&
|
||||
!normalizedSearchTerm &&
|
||||
lockedEmojiCount > 0;
|
||||
const handleCategoryClick = (category: string) => {
|
||||
const element = categoryRefs.current.get(category);
|
||||
if (element) {
|
||||
scrollerRef.current?.revealElement({node: element, preferStartEdge: true});
|
||||
const categoryRowIndexes = useMemo(() => {
|
||||
const categories = new Map<string, number>();
|
||||
for (let rowIndex = 0; rowIndex < pickerRows.length; rowIndex += 1) {
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
const searchBar = !hideSearchBar ? (
|
||||
<EmojiPickerSearchBar
|
||||
searchTerm={searchTerm}
|
||||
@@ -256,7 +303,7 @@ export const MobileEmojiPicker = observer(
|
||||
ref={scrollerRef}
|
||||
className={`${mobileStyles.list} ${mobileStyles.listWrapper}`}
|
||||
key="mobile-emoji_picker-scroller"
|
||||
data-emoji-picker-scroll-root="true"
|
||||
onScroll={handleScroll}
|
||||
onResize={handleResize}
|
||||
data-flx="channel.mobile-emoji-picker.scroller"
|
||||
>
|
||||
@@ -269,18 +316,20 @@ export const MobileEmojiPicker = observer(
|
||||
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
|
||||
key={`${row.type}-${row.index}`}
|
||||
ref={
|
||||
row.type === 'header'
|
||||
? (el) => {
|
||||
if (el && 'category' in row) {
|
||||
categoryRefs.current.set(row.category, el);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: `translateY(${rowOffsets[rowWindow.firstRow + windowIndex]!}px)`,
|
||||
}}
|
||||
data-flx="channel.mobile-emoji-picker.div--4"
|
||||
>
|
||||
<VirtualizedRow
|
||||
@@ -292,7 +341,6 @@ export const MobileEmojiPicker = observer(
|
||||
allowAnimation={shouldAnimateEmoji}
|
||||
gridColumns={gridColumns}
|
||||
gridWidth={gridWidth}
|
||||
hoveredEmoji={hoveredEmoji}
|
||||
selectedRow={-1}
|
||||
selectedColumn={-1}
|
||||
emojiRowIndex={0}
|
||||
@@ -301,6 +349,7 @@ export const MobileEmojiPicker = observer(
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Scroller>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 sprite1f3fb1x from '@app/media/images/emoji-sprites/spritesheet-1f3fb.png';
|
||||
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 EMOJIS_PER_ROW = 9;
|
||||
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;
|
||||
|
||||
function getRemScale(): number {
|
||||
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 const EMOJI_GRID_TRACK_WIDTH = EMOJI_GRID_MIN_CELL_WIDTH;
|
||||
export const EMOJI_GRID_SCROLLER_PADDING_WIDTH = 8;
|
||||
|
||||
export function getEmojiGridColumns(containerWidth: number): number {
|
||||
if (containerWidth <= 0) return EMOJIS_PER_ROW;
|
||||
const remScale = getRemScale();
|
||||
const availableWidth = Math.max(0, containerWidth - EMOJI_GRID_INLINE_PADDING * remScale);
|
||||
const columns = Math.floor(availableWidth / (EMOJI_GRID_MIN_CELL_WIDTH * remScale));
|
||||
const remScale = getRemScaleForDocument(typeof document === 'undefined' ? null : document);
|
||||
const columns = Math.floor(containerWidth / (EMOJI_GRID_MIN_CELL_WIDTH * remScale));
|
||||
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 => {
|
||||
const column = index % perRow;
|
||||
const row = Math.floor(index / perRow);
|
||||
|
||||
@@ -4,7 +4,6 @@ import styles from '@app/features/channel/components/EmojiPicker.module.css';
|
||||
import {
|
||||
CATEGORY_HEADER_HEIGHT,
|
||||
EMOJI_ROW_HEIGHT,
|
||||
OVERSCAN_ROWS,
|
||||
} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants';
|
||||
import {EmojiRenderer} from '@app/features/channel/components/emoji_picker/EmojiRenderer';
|
||||
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 {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
|
||||
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 {CaretDownIcon, ClockIcon, StarIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
export type VirtualRow =
|
||||
| {type: 'header'; category: string; name: string; guildId?: string; index: number}
|
||||
@@ -32,9 +30,8 @@ interface VirtualRowRendererProps {
|
||||
shouldAnimate: boolean;
|
||||
gridColumns?: number;
|
||||
gridWidth?: number;
|
||||
hoveredEmoji: FlatEmoji | null;
|
||||
selectedRow: number;
|
||||
selectedColumn: number;
|
||||
cellTrackWidth?: number;
|
||||
selectedColumnInRow: number;
|
||||
emojiRowIndex: number;
|
||||
shouldScrollOnSelection?: boolean;
|
||||
emojiRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>;
|
||||
@@ -50,8 +47,8 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
|
||||
shouldAnimate,
|
||||
gridColumns = 9,
|
||||
gridWidth,
|
||||
selectedRow,
|
||||
selectedColumn,
|
||||
cellTrackWidth,
|
||||
selectedColumnInRow,
|
||||
emojiRowIndex,
|
||||
shouldScrollOnSelection = false,
|
||||
emojiRefs,
|
||||
@@ -98,7 +95,6 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleCategory}
|
||||
className={styles.categoryTitle}
|
||||
style={{
|
||||
height: remFromPx(CATEGORY_HEADER_HEIGHT),
|
||||
display: 'flex',
|
||||
@@ -146,6 +142,7 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const hasFixedTrack = cellTrackWidth != null && cellTrackWidth > 0;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -155,20 +152,22 @@ const VirtualRowRenderer: React.FC<VirtualRowRendererProps> = React.memo(
|
||||
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.div--2"
|
||||
>
|
||||
<div
|
||||
className={styles.emojiGrid}
|
||||
className={hasFixedTrack ? `${styles.emojiGrid} ${styles.emojiGridFixedTrack}` : styles.emojiGrid}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: hasFixedTrack
|
||||
? `repeat(auto-fill, ${remFromPx(cellTrackWidth)})`
|
||||
: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
marginInline: gridWidth ? 'auto' : undefined,
|
||||
width: gridWidth ? `${gridWidth}px` : undefined,
|
||||
}}
|
||||
data-flx="channel.emoji-picker.virtual-row.virtual-row-renderer.emoji-grid"
|
||||
>
|
||||
{row.emojis.map((emoji, colIndex) => {
|
||||
const isSelected = emojiRowIndex === selectedRow && colIndex === selectedColumn;
|
||||
const isSelected = colIndex === selectedColumnInRow;
|
||||
const shouldHighlight = isSelected;
|
||||
return (
|
||||
<EmojiRenderer
|
||||
key={emoji.name}
|
||||
key={emoji.id ?? emoji.uniqueName}
|
||||
emoji={emoji}
|
||||
handleHover={(e) => handleHover(e, emojiRowIndex, colIndex)}
|
||||
handleSelect={handleSelect}
|
||||
@@ -204,7 +203,7 @@ interface VirtualizedRowProps {
|
||||
allowAnimation: boolean;
|
||||
gridColumns?: number;
|
||||
gridWidth?: number;
|
||||
hoveredEmoji: FlatEmoji | null;
|
||||
cellTrackWidth?: number;
|
||||
selectedRow: number;
|
||||
selectedColumn: number;
|
||||
emojiRowIndex: number;
|
||||
@@ -222,79 +221,31 @@ export const VirtualizedRow: React.FC<VirtualizedRowProps> = observer(
|
||||
allowAnimation,
|
||||
gridColumns,
|
||||
gridWidth,
|
||||
hoveredEmoji,
|
||||
cellTrackWidth,
|
||||
selectedRow,
|
||||
selectedColumn,
|
||||
emojiRowIndex,
|
||||
shouldScrollOnSelection = false,
|
||||
emojiRefs,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
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) {
|
||||
const selectedColumnInRow = emojiRowIndex === selectedRow ? selectedColumn : -1;
|
||||
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
|
||||
row={row}
|
||||
handleHover={handleHover}
|
||||
handleSelect={handleSelect}
|
||||
skinTone={skinTone}
|
||||
channel={channel}
|
||||
shouldAnimate={allowAnimation && isInViewport}
|
||||
shouldAnimate={allowAnimation}
|
||||
gridColumns={gridColumns}
|
||||
gridWidth={gridWidth}
|
||||
hoveredEmoji={hoveredEmoji}
|
||||
selectedRow={selectedRow}
|
||||
selectedColumn={selectedColumn}
|
||||
cellTrackWidth={cellTrackWidth}
|
||||
selectedColumnInRow={selectedColumnInRow}
|
||||
emojiRowIndex={emojiRowIndex}
|
||||
shouldScrollOnSelection={shouldScrollOnSelection}
|
||||
emojiRefs={emojiRefs}
|
||||
data-flx="channel.emoji-picker.virtual-row.virtualized-row.virtual-row-renderer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user