feat(component): virtual scroll emoji groups in emoji picker (#13671)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
- Revamped Emoji Picker: grouped browsing with sticky group headers,
footer navigation, and a new EmojiButton for quicker selection.
  - Recent emojis with persisted history and single-tap add.
- Programmatic group navigation and callbacks for sticky-group changes.

- Style
  - Updated scroll area paddings for emoji and icon pickers.
  - Enhanced group header background for better contrast.

- Refactor
- Simplified emoji picker internals for leaner, more responsive
rendering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-09-30 09:59:39 +08:00
committed by GitHub
parent 123d50a484
commit b44fdbce0c
9 changed files with 380 additions and 268 deletions

View File

@@ -148,7 +148,7 @@ export const AffineIconPicker = ({
</header>
{/* Content */}
<Scrollable.Root className={pickerStyles.scrollRoot}>
<Scrollable.Root className={pickerStyles.iconScrollRoot}>
<Scrollable.Viewport className={pickerStyles.scrollViewport}>
{/* Recent */}
{recentIcons.length ? (

View File

@@ -0,0 +1,26 @@
import { memo, useCallback } from 'react';
import { IconButton } from '../../../button';
// Memoized individual emoji button to prevent unnecessary re-renders
export const EmojiButton = memo(function EmojiButton({
emoji,
onSelect,
}: {
emoji: string;
onSelect: (emoji: string) => void;
}) {
const handleClick = useCallback(() => {
onSelect(emoji);
}, [emoji, onSelect]);
return (
<IconButton
key={emoji}
size={24}
style={{ padding: 4 }}
icon={<span>{emoji}</span>}
onClick={handleClick}
/>
);
});

View File

@@ -1,145 +1,15 @@
import { RecentIcon, SearchIcon } from '@blocksuite/icons/rc';
import { SearchIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import clsx from 'clsx';
import {
memo,
startTransition,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useCallback, useState } from 'react';
import { IconButton } from '../../../button';
import Input from '../../../input';
import { Loading } from '../../../loading';
import { Menu } from '../../../menu';
import { Scrollable } from '../../../scrollbar';
import * as pickerStyles from '../picker.css';
import { GROUP_ICON_MAP, type GroupName, GROUPS } from './constants';
import rawData from './data/en.json';
// import { emojiGroupList } from './gen-data';
import * as styles from './emoji-picker.css';
import type { CompactEmoji } from './type';
type EmojiGroup = {
name: string;
emojis: Array<CompactEmoji>;
};
const emojiGroupList = rawData as EmojiGroup[];
const useRecentEmojis = () => {
const [recentEmojis, setRecentEmojis] = useState<Array<string>>([]);
useEffect(() => {
const recentEmojis = localStorage.getItem('recentEmojis');
setRecentEmojis(recentEmojis ? recentEmojis.split(',') : []);
}, []);
const add = useCallback((emoji: string) => {
setRecentEmojis(prevRecentEmojis => {
const newRecentEmojis = [
emoji,
...prevRecentEmojis.filter(e => e !== emoji),
].slice(0, 10);
localStorage.setItem('recentEmojis', newRecentEmojis.join(','));
return newRecentEmojis;
});
}, []);
return {
recentEmojis,
add,
};
};
// Memoized individual emoji button to prevent unnecessary re-renders
const EmojiButton = memo(function EmojiButton({
emoji,
onSelect,
}: {
emoji: string;
onSelect: (emoji: string) => void;
}) {
const handleClick = useCallback(() => {
onSelect(emoji);
}, [emoji, onSelect]);
return (
<IconButton
key={emoji}
size={24}
style={{ padding: 4 }}
icon={<span>{emoji}</span>}
onClick={handleClick}
/>
);
});
// Memoized emoji groups to prevent unnecessary re-renders
const EmojiGroups = memo(function EmojiGroups({
onSelect,
keyword,
skin,
}: {
onSelect: (emoji: string) => void;
keyword?: string;
skin?: number;
}) {
const [groups, setGroups] = useState<EmojiGroup[]>([]);
const loading = !keyword && !groups.length;
useEffect(() => {
startTransition(() => {
if (!keyword) {
setGroups(emojiGroupList);
return;
}
setGroups(
emojiGroupList
.map(group => ({
...group,
emojis: group.emojis.filter(emoji =>
emoji.tags?.some(tag => tag.includes(keyword.toLowerCase()))
),
}))
.filter(group => group.emojis.length > 0)
);
});
}, [keyword]);
if (loading) {
return (
<div className={styles.loadingWrapper}>
<Loading size={16} />
<span style={{ marginLeft: 4 }}>Loading emojis...</span>
</div>
);
}
return groups.map(group => (
<div key={group.name} className={pickerStyles.group}>
<div className={pickerStyles.groupName} data-group-name={group.name}>
{group.name}
</div>
<div className={pickerStyles.groupGrid}>
{group.emojis.map(emoji => (
<EmojiButton
key={emoji.label}
emoji={
skin !== undefined && emoji.skins
? (emoji.skins[skin]?.unicode ?? emoji.unicode)
: emoji.unicode
}
onSelect={onSelect}
/>
))}
</div>
</div>
));
});
import { EmojiGroups } from './groups';
import { useRecentEmojis } from './recent';
const skinList = [
{ unicode: '👋', value: undefined },
@@ -155,54 +25,10 @@ export const EmojiPicker = ({
}: {
onSelect?: (emoji: string) => void;
}) => {
const scrollableRef = useRef<HTMLDivElement>(null);
const [keyword, setKeyword] = useState<string>('');
const [activeGroupId, setActiveGroupId] = useState<string | undefined>(
undefined
);
const [skin, setSkin] = useState<number | undefined>(undefined);
const { recentEmojis, add: addRecent } = useRecentEmojis();
const checkActiveGroup = useCallback(() => {
const scrollable = scrollableRef.current;
if (!scrollable) return;
// get actual scrollable element
const viewport = scrollable.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement;
if (!viewport) return;
const scrollTop = viewport.scrollTop;
// find the first group that is at the top of the scrollable element
for (let i = emojiGroupList.length - 1; i >= 0; i--) {
const group = emojiGroupList[i];
const groupElement = viewport.querySelector(
`[data-group-name="${group.name}"]`
) as HTMLElement;
if (!groupElement) continue;
// use offsetTop to get the position of the element relative to the scrollable element
const elementTop = groupElement.offsetTop;
if (elementTop <= scrollTop + 50) {
setActiveGroupId(group.name);
return;
}
}
}, []);
const jumpToGroup = useCallback((groupName: string) => {
const groupElement = scrollableRef.current?.querySelector(
`[data-group-name="${groupName}"]`
) as HTMLElement;
if (!groupElement) return;
setActiveGroupId(groupName);
groupElement.scrollIntoView({ behavior: 'smooth' });
}, []);
const { add: addRecent, recentEmojis } = useRecentEmojis();
const handleEmojiSelect = useCallback(
(emoji: string) => {
@@ -212,10 +38,6 @@ export const EmojiPicker = ({
[addRecent, onSelect]
);
useEffect(() => {
checkActiveGroup();
}, [checkActiveGroup]);
return (
<div className={pickerStyles.root}>
<header className={pickerStyles.searchContainer}>
@@ -271,62 +93,14 @@ export const EmojiPicker = ({
/>
</Menu>
</header>
<Scrollable.Root className={pickerStyles.scrollRoot} ref={scrollableRef}>
<Scrollable.Viewport
onScrollEnd={checkActiveGroup}
className={pickerStyles.scrollViewport}
>
{/* Recent */}
{recentEmojis.length ? (
<div className={pickerStyles.group}>
<div className={pickerStyles.groupName} data-group-name="Recent">
Recent
</div>
<div className={pickerStyles.groupGrid}>
{recentEmojis.map(emoji => (
<EmojiButton
key={emoji}
emoji={emoji}
onSelect={handleEmojiSelect}
/>
))}
</div>
</div>
) : null}
{/* Groups */}
<EmojiGroups
onSelect={handleEmojiSelect}
keyword={keyword}
skin={skin}
/>
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
<div className={styles.footer}>
{['Recent', ...GROUPS].map(group => {
const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon;
const active = activeGroupId === group;
return (
<IconButton
size={18}
style={{ padding: 3 }}
key={group}
icon={
<Icon
className={
active ? styles.footerIconActive : styles.footerIcon
}
/>
}
className={clsx(
active ? styles.footerButtonActive : styles.footerButton
)}
onClick={() => jumpToGroup(group)}
/>
);
})}
</div>
{/* Groups */}
<EmojiGroups
recent={recentEmojis}
onSelect={handleEmojiSelect}
keyword={keyword}
skin={skin}
/>
</div>
);
};

View File

@@ -0,0 +1,227 @@
import { RecentIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import {
createContext,
memo,
startTransition,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { IconButton } from '../../../button';
import { Loading } from '../../../loading';
import {
Masonry,
type MasonryGroup,
type MasonryItem,
type MasonryRef,
} from '../../../masonry';
import * as pickerStyles from '../picker.css';
import { GROUP_ICON_MAP, type GroupName, GROUPS } from './constants';
import rawData from './data/en.json';
import { EmojiButton } from './emoji-button';
import * as styles from './emoji-picker.css';
import type { CompactEmoji, EmojiGroup } from './type';
const emojiGroupList = rawData as EmojiGroup[];
const initEmojiGroupMap = () => {
const emojiGroupMap = new Map<string, Map<string, CompactEmoji>>();
emojiGroupList.forEach(group => {
emojiGroupMap.set(
group.name,
new Map(group.emojis.map(emoji => [emoji.label, emoji]))
);
});
return emojiGroupMap;
};
const emojiGroupMap = initEmojiGroupMap();
const EmojiGroupContext = createContext<{
onSelect: (emoji: string) => void;
skin?: number;
}>({
onSelect: () => {},
});
const RecentGroupItem = memo(function RecentGroupItem({
itemId,
}: {
itemId: string;
}) {
const { onSelect } = useContext(EmojiGroupContext);
return <EmojiButton emoji={itemId} onSelect={onSelect} />;
});
const EmojiGroupItem = memo(function EmojiGroupItem({
groupId,
itemId,
}: {
groupId: string;
itemId: string;
}) {
const emoji = emojiGroupMap.get(groupId)?.get(itemId);
const { onSelect, skin } = useContext(EmojiGroupContext);
if (!emoji) return null;
return (
<EmojiButton
emoji={
skin !== undefined && emoji.skins
? (emoji.skins[skin]?.unicode ?? emoji.unicode)
: emoji.unicode
}
onSelect={onSelect}
/>
);
});
const EmojiGroupHeader = memo(function EmojiGroupHeader({
groupId,
}: {
groupId: string;
}) {
return (
<div className={pickerStyles.groupName} data-group-name={groupId}>
{groupId}
</div>
);
});
// Memoized emoji groups to prevent unnecessary re-renders
export const EmojiGroups = memo(function EmojiGroups({
recent,
onSelect,
keyword,
skin,
}: {
onSelect: (emoji: string) => void;
recent?: string[];
keyword?: string;
skin?: number;
}) {
const masonryRef = useRef<MasonryRef>(null);
const [activeGroupId, setActiveGroupId] = useState<string | undefined>(
'Recent'
);
const [groups, setGroups] = useState<EmojiGroup[]>([]);
const loading = !keyword && !groups.length;
useEffect(() => {
if (!keyword) {
setGroups(emojiGroupList);
return;
}
startTransition(() => {
setGroups(
emojiGroupList
.map(group => ({
...group,
emojis: group.emojis.filter(emoji =>
emoji.tags?.some(tag => tag.includes(keyword.toLowerCase()))
),
}))
.filter(group => group.emojis.length > 0)
);
});
}, [keyword]);
const items = useMemo(() => {
const emojiGroups = groups.map(group => {
return {
id: group.name,
height: 30,
Component: EmojiGroupHeader,
items: group.emojis.map(emoji => {
return {
id: emoji.label,
height: 32,
ratio: 1,
Component: EmojiGroupItem,
} satisfies MasonryItem;
}),
} satisfies MasonryGroup;
});
if (recent?.length) {
emojiGroups.unshift({
id: 'Recent',
height: 30,
Component: EmojiGroupHeader,
items: recent.map(emoji => {
return {
id: emoji,
height: 32,
ratio: 1,
Component: RecentGroupItem,
} satisfies MasonryItem;
}),
});
}
return emojiGroups;
}, [groups, recent]);
const contextValue = useMemo(() => ({ onSelect, skin }), [onSelect, skin]);
const jumpToGroup = useCallback((groupName: string) => {
setActiveGroupId(groupName);
masonryRef.current?.scrollToGroup(groupName);
}, []);
if (loading) {
return (
<div className={styles.loadingWrapper}>
<Loading size={16} />
<span style={{ marginLeft: 4 }}>Loading emojis...</span>
</div>
);
}
return (
<EmojiGroupContext.Provider value={contextValue}>
<div className={pickerStyles.emojiScrollRoot}>
<Masonry
ref={masonryRef}
virtualScroll
items={items}
itemWidthMin={32}
itemWidth={32}
paddingX={12}
paddingY={8}
gapX={4}
gapY={4}
onStickyGroupChange={setActiveGroupId}
/>
</div>
<div className={styles.footer}>
{['Recent', ...GROUPS].map(group => {
const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon;
const active = activeGroupId === group;
return (
<IconButton
size={18}
style={{ padding: 3 }}
key={group}
icon={
<Icon
className={
active ? styles.footerIconActive : styles.footerIcon
}
/>
}
className={clsx(
active ? styles.footerButtonActive : styles.footerButton
)}
onClick={() => jumpToGroup(group)}
/>
);
})}
</div>
</EmojiGroupContext.Provider>
);
});

View File

@@ -0,0 +1,26 @@
import { useCallback, useEffect, useState } from 'react';
export const useRecentEmojis = () => {
const [recentEmojis, setRecentEmojis] = useState<Array<string>>([]);
useEffect(() => {
const recentEmojis = localStorage.getItem('recentEmojis');
setRecentEmojis(recentEmojis ? recentEmojis.split(',') : []);
}, []);
const add = useCallback((emoji: string) => {
setRecentEmojis(prevRecentEmojis => {
const newRecentEmojis = [
emoji,
...prevRecentEmojis.filter(e => e !== emoji),
].slice(0, 10);
localStorage.setItem('recentEmojis', newRecentEmojis.join(','));
return newRecentEmojis;
});
}, []);
return {
recentEmojis,
add,
};
};

View File

@@ -9,3 +9,8 @@ export type CompactEmoji = {
unicode: string;
skins?: Array<Omit<CompactEmoji, 'skins'>>;
};
export type EmojiGroup = {
name: string;
emojis: Array<CompactEmoji>;
};

View File

@@ -27,8 +27,19 @@ export const searchInput = style({
export const scrollRoot = style({
height: 0,
flexGrow: 1,
padding: '0px 12px',
});
export const emojiScrollRoot = style([
scrollRoot,
{
paddingTop: '8px',
},
]);
export const iconScrollRoot = style([
scrollRoot,
{
padding: '0px 12px',
},
]);
export const scrollViewport = style({
padding: '8px 0px',
@@ -52,6 +63,7 @@ export const groupName = style({
display: 'flex',
alignItems: 'center',
padding: '0px 4px',
backgroundColor: cssVarV2.layer.background.overlayPanel,
});
export const groupGrid = style({

View File

@@ -2,10 +2,12 @@ import clsx from 'clsx';
import { debounce } from 'lodash-es';
import throttle from 'lodash-es/throttle';
import {
forwardRef,
Fragment,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
@@ -61,29 +63,39 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
columns?: number;
resizeDebounce?: number;
preloadHeight?: number;
onStickyGroupChange?: (groupId?: string) => void;
}
export const Masonry = ({
items,
gapX = 12,
gapY = 12,
itemWidth = 'stretch',
itemWidthMin = 100,
paddingX = 0,
paddingY = 0,
className,
virtualScroll = false,
locateMode = 'leftTop',
groupsGap = 0,
groupHeaderGapWithItems = 0,
stickyGroupHeader = true,
collapsedGroups,
columns,
preloadHeight = 50,
resizeDebounce = 20,
onGroupCollapse,
...props
}: MasonryProps) => {
export type MasonryRef = {
scrollToGroup: (groupId: string) => void;
};
export const Masonry = forwardRef<MasonryRef, MasonryProps>(function Masonry(
{
items,
gapX = 12,
gapY = 12,
itemWidth = 'stretch',
itemWidthMin = 100,
paddingX = 0,
paddingY = 0,
className,
virtualScroll = false,
locateMode = 'leftTop',
groupsGap = 0,
groupHeaderGapWithItems = 0,
stickyGroupHeader = true,
collapsedGroups,
columns,
preloadHeight = 50,
resizeDebounce = 20,
onGroupCollapse,
onStickyGroupChange,
...props
},
ref
) {
const rootRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
const [layoutMap, setLayoutMap] = useState<
@@ -212,7 +224,9 @@ export const Masonry = ({
const scrollY = (e.target as HTMLElement).scrollTop;
updateActiveMap(layoutMap, scrollY);
if (stickyGroupHeader) {
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
const stickyGroupId = calcSticky({ scrollY, layoutMap });
setStickyGroupId(stickyGroupId);
onStickyGroupChange?.(stickyGroupId);
}
}, 50);
rootEl.addEventListener('scroll', handler);
@@ -221,7 +235,29 @@ export const Masonry = ({
};
}
return;
}, [layoutMap, stickyGroupHeader, updateActiveMap, virtualScroll]);
}, [
layoutMap,
onStickyGroupChange,
stickyGroupHeader,
updateActiveMap,
virtualScroll,
]);
const scrollToGroup = useCallback(
(groupId: string) => {
const group = layoutMap.get(groupId);
if (!group) return;
rootRef.current?.scrollTo({
top: group.y,
behavior: 'instant',
});
},
[layoutMap]
);
useImperativeHandle<MasonryRef, MasonryRef>(ref, () => {
return { scrollToGroup };
});
return (
<Scrollable.Root>
@@ -312,7 +348,7 @@ export const Masonry = ({
<Scrollable.Scrollbar className={styles.scrollbar} />
</Scrollable.Root>
);
};
});
type MasonryItemProps = MasonryItem &
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> & {

View File

@@ -112,12 +112,18 @@ export const calcLayout = (
const ratioMode = 'ratio' in item;
const height = ratioMode ? item.ratio * width : item.height;
const aroundGapXValue =
columns > 1
? (totalWidth - paddingX * 2 - width * columns) / (columns - 1)
: 0;
const gapXValue = Math.max(gapX, aroundGapXValue);
if (ratioMode) {
const minRatio = Math.min(...ratioStack);
const minRatioIndex = ratioStack.indexOf(minRatio);
const minHeight = heightStack[minRatioIndex];
const hasGap = heightStack[minRatioIndex] ? gapY : 0;
const x = minRatioIndex * (width + gapX) + paddingX;
const x = minRatioIndex * (width + gapXValue) + paddingX;
const y = finalHeight + minHeight + hasGap;
ratioStack[minRatioIndex] += item.ratio * 10000;
@@ -133,7 +139,7 @@ export const calcLayout = (
const minHeight = Math.min(...heightStack);
const minHeightIndex = heightStack.indexOf(minHeight);
const hasGap = heightStack[minHeightIndex] ? gapY : 0;
const x = minHeightIndex * (width + gapX) + paddingX;
const x = minHeightIndex * (width + gapXValue) + paddingX;
const y = finalHeight + minHeight + hasGap;
const ratio = height / width;
@@ -193,7 +199,7 @@ export const calcSticky = (options: {
const stickyGroupEntry = groupEntries.find(([_, xywh], index) => {
const next = groupEntries[index + 1];
return xywh.y < scrollY && (!next || next[1].y > scrollY);
return xywh.y <= scrollY && (!next || next[1].y > scrollY);
});
return stickyGroupEntry