From b44fdbce0c50d633dc24840f84d5d29b22c6c2e3 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Tue, 30 Sep 2025 09:59:39 +0800 Subject: [PATCH] feat(component): virtual scroll emoji groups in emoji picker (#13671) ## 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. --- .../picker/affine-icon/affine-icon-picker.tsx | 2 +- .../icon-picker/picker/emoji/emoji-button.tsx | 26 ++ .../icon-picker/picker/emoji/emoji-picker.tsx | 252 +----------------- .../ui/icon-picker/picker/emoji/groups.tsx | 227 ++++++++++++++++ .../src/ui/icon-picker/picker/emoji/recent.ts | 26 ++ .../src/ui/icon-picker/picker/emoji/type.ts | 5 + .../src/ui/icon-picker/picker/picker.css.ts | 14 +- .../component/src/ui/masonry/masonry.tsx | 84 ++++-- .../component/src/ui/masonry/utils.ts | 12 +- 9 files changed, 380 insertions(+), 268 deletions(-) create mode 100644 packages/frontend/component/src/ui/icon-picker/picker/emoji/emoji-button.tsx create mode 100644 packages/frontend/component/src/ui/icon-picker/picker/emoji/groups.tsx create mode 100644 packages/frontend/component/src/ui/icon-picker/picker/emoji/recent.ts diff --git a/packages/frontend/component/src/ui/icon-picker/picker/affine-icon/affine-icon-picker.tsx b/packages/frontend/component/src/ui/icon-picker/picker/affine-icon/affine-icon-picker.tsx index 673bb3973b..6a315e1947 100644 --- a/packages/frontend/component/src/ui/icon-picker/picker/affine-icon/affine-icon-picker.tsx +++ b/packages/frontend/component/src/ui/icon-picker/picker/affine-icon/affine-icon-picker.tsx @@ -148,7 +148,7 @@ export const AffineIconPicker = ({ {/* Content */} - + {/* Recent */} {recentIcons.length ? ( diff --git a/packages/frontend/component/src/ui/icon-picker/picker/emoji/emoji-button.tsx b/packages/frontend/component/src/ui/icon-picker/picker/emoji/emoji-button.tsx new file mode 100644 index 0000000000..6a93b89722 --- /dev/null +++ b/packages/frontend/component/src/ui/icon-picker/picker/emoji/emoji-button.tsx @@ -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 ( + {emoji}} + onClick={handleClick} + /> + ); +}); diff --git a/packages/frontend/component/src/ui/icon-picker/picker/emoji/emoji-picker.tsx b/packages/frontend/component/src/ui/icon-picker/picker/emoji/emoji-picker.tsx index ace2ffddbf..5bde85b4f1 100644 --- a/packages/frontend/component/src/ui/icon-picker/picker/emoji/emoji-picker.tsx +++ b/packages/frontend/component/src/ui/icon-picker/picker/emoji/emoji-picker.tsx @@ -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; -}; -const emojiGroupList = rawData as EmojiGroup[]; - -const useRecentEmojis = () => { - const [recentEmojis, setRecentEmojis] = useState>([]); - - 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 ( - {emoji}} - 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([]); - - 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 ( -
- - Loading emojis... -
- ); - } - - return groups.map(group => ( -
-
- {group.name} -
-
- {group.emojis.map(emoji => ( - - ))} -
-
- )); -}); +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(null); - const [keyword, setKeyword] = useState(''); - const [activeGroupId, setActiveGroupId] = useState( - undefined - ); + const [skin, setSkin] = useState(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 (
@@ -271,62 +93,14 @@ export const EmojiPicker = ({ />
- - - {/* Recent */} - {recentEmojis.length ? ( -
-
- Recent -
-
- {recentEmojis.map(emoji => ( - - ))} -
-
- ) : null} - {/* Groups */} - -
- -
-
- {['Recent', ...GROUPS].map(group => { - const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon; - const active = activeGroupId === group; - return ( - - } - className={clsx( - active ? styles.footerButtonActive : styles.footerButton - )} - onClick={() => jumpToGroup(group)} - /> - ); - })} -
+ {/* Groups */} +
); }; diff --git a/packages/frontend/component/src/ui/icon-picker/picker/emoji/groups.tsx b/packages/frontend/component/src/ui/icon-picker/picker/emoji/groups.tsx new file mode 100644 index 0000000000..ad828109c3 --- /dev/null +++ b/packages/frontend/component/src/ui/icon-picker/picker/emoji/groups.tsx @@ -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>(); + 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 ; +}); +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 ( + + ); +}); +const EmojiGroupHeader = memo(function EmojiGroupHeader({ + groupId, +}: { + groupId: string; +}) { + return ( +
+ {groupId} +
+ ); +}); + +// 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(null); + const [activeGroupId, setActiveGroupId] = useState( + 'Recent' + ); + const [groups, setGroups] = useState([]); + + 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 ( +
+ + Loading emojis... +
+ ); + } + + return ( + +
+ +
+
+ {['Recent', ...GROUPS].map(group => { + const Icon = GROUP_ICON_MAP[group as GroupName] ?? RecentIcon; + const active = activeGroupId === group; + return ( + + } + className={clsx( + active ? styles.footerButtonActive : styles.footerButton + )} + onClick={() => jumpToGroup(group)} + /> + ); + })} +
+
+ ); +}); diff --git a/packages/frontend/component/src/ui/icon-picker/picker/emoji/recent.ts b/packages/frontend/component/src/ui/icon-picker/picker/emoji/recent.ts new file mode 100644 index 0000000000..da5628ed29 --- /dev/null +++ b/packages/frontend/component/src/ui/icon-picker/picker/emoji/recent.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useState } from 'react'; + +export const useRecentEmojis = () => { + const [recentEmojis, setRecentEmojis] = useState>([]); + + 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, + }; +}; diff --git a/packages/frontend/component/src/ui/icon-picker/picker/emoji/type.ts b/packages/frontend/component/src/ui/icon-picker/picker/emoji/type.ts index 8668aa3ac4..4c40079463 100644 --- a/packages/frontend/component/src/ui/icon-picker/picker/emoji/type.ts +++ b/packages/frontend/component/src/ui/icon-picker/picker/emoji/type.ts @@ -9,3 +9,8 @@ export type CompactEmoji = { unicode: string; skins?: Array>; }; + +export type EmojiGroup = { + name: string; + emojis: Array; +}; diff --git a/packages/frontend/component/src/ui/icon-picker/picker/picker.css.ts b/packages/frontend/component/src/ui/icon-picker/picker/picker.css.ts index 8c47b76c75..398676e607 100644 --- a/packages/frontend/component/src/ui/icon-picker/picker/picker.css.ts +++ b/packages/frontend/component/src/ui/icon-picker/picker/picker.css.ts @@ -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({ diff --git a/packages/frontend/component/src/ui/masonry/masonry.tsx b/packages/frontend/component/src/ui/masonry/masonry.tsx index cb49362772..1a7d5f76cb 100644 --- a/packages/frontend/component/src/ui/masonry/masonry.tsx +++ b/packages/frontend/component/src/ui/masonry/masonry.tsx @@ -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 { 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(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(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(ref, () => { + return { scrollToGroup }; + }); return ( @@ -312,7 +348,7 @@ export const Masonry = ({ ); -}; +}); type MasonryItemProps = MasonryItem & Omit, 'id' | 'height'> & { diff --git a/packages/frontend/component/src/ui/masonry/utils.ts b/packages/frontend/component/src/ui/masonry/utils.ts index 5f17bf3202..3c7e9e406c 100644 --- a/packages/frontend/component/src/ui/masonry/utils.ts +++ b/packages/frontend/component/src/ui/masonry/utils.ts @@ -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