mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-23 13:02:21 +08:00
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:
+1
-1
@@ -148,7 +148,7 @@ export const AffineIconPicker = ({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Scrollable.Root className={pickerStyles.scrollRoot}>
|
<Scrollable.Root className={pickerStyles.iconScrollRoot}>
|
||||||
<Scrollable.Viewport className={pickerStyles.scrollViewport}>
|
<Scrollable.Viewport className={pickerStyles.scrollViewport}>
|
||||||
{/* Recent */}
|
{/* Recent */}
|
||||||
{recentIcons.length ? (
|
{recentIcons.length ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,145 +1,15 @@
|
|||||||
import { RecentIcon, SearchIcon } from '@blocksuite/icons/rc';
|
import { SearchIcon } from '@blocksuite/icons/rc';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import clsx from 'clsx';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
|
||||||
memo,
|
|
||||||
startTransition,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { IconButton } from '../../../button';
|
import { IconButton } from '../../../button';
|
||||||
import Input from '../../../input';
|
import Input from '../../../input';
|
||||||
import { Loading } from '../../../loading';
|
|
||||||
import { Menu } from '../../../menu';
|
import { Menu } from '../../../menu';
|
||||||
import { Scrollable } from '../../../scrollbar';
|
|
||||||
import * as pickerStyles from '../picker.css';
|
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 { emojiGroupList } from './gen-data';
|
||||||
import * as styles from './emoji-picker.css';
|
import * as styles from './emoji-picker.css';
|
||||||
import type { CompactEmoji } from './type';
|
import { EmojiGroups } from './groups';
|
||||||
|
import { useRecentEmojis } from './recent';
|
||||||
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>
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
const skinList = [
|
const skinList = [
|
||||||
{ unicode: '👋', value: undefined },
|
{ unicode: '👋', value: undefined },
|
||||||
@@ -155,54 +25,10 @@ export const EmojiPicker = ({
|
|||||||
}: {
|
}: {
|
||||||
onSelect?: (emoji: string) => void;
|
onSelect?: (emoji: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [keyword, setKeyword] = useState<string>('');
|
const [keyword, setKeyword] = useState<string>('');
|
||||||
const [activeGroupId, setActiveGroupId] = useState<string | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
const [skin, setSkin] = useState<number | undefined>(undefined);
|
const [skin, setSkin] = useState<number | undefined>(undefined);
|
||||||
const { recentEmojis, add: addRecent } = useRecentEmojis();
|
const { add: addRecent, recentEmojis } = 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 handleEmojiSelect = useCallback(
|
const handleEmojiSelect = useCallback(
|
||||||
(emoji: string) => {
|
(emoji: string) => {
|
||||||
@@ -212,10 +38,6 @@ export const EmojiPicker = ({
|
|||||||
[addRecent, onSelect]
|
[addRecent, onSelect]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkActiveGroup();
|
|
||||||
}, [checkActiveGroup]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={pickerStyles.root}>
|
<div className={pickerStyles.root}>
|
||||||
<header className={pickerStyles.searchContainer}>
|
<header className={pickerStyles.searchContainer}>
|
||||||
@@ -271,62 +93,14 @@ export const EmojiPicker = ({
|
|||||||
/>
|
/>
|
||||||
</Menu>
|
</Menu>
|
||||||
</header>
|
</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 */}
|
{/* Groups */}
|
||||||
<EmojiGroups
|
<EmojiGroups
|
||||||
onSelect={handleEmojiSelect}
|
recent={recentEmojis}
|
||||||
keyword={keyword}
|
onSelect={handleEmojiSelect}
|
||||||
skin={skin}
|
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -9,3 +9,8 @@ export type CompactEmoji = {
|
|||||||
unicode: string;
|
unicode: string;
|
||||||
skins?: Array<Omit<CompactEmoji, 'skins'>>;
|
skins?: Array<Omit<CompactEmoji, 'skins'>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EmojiGroup = {
|
||||||
|
name: string;
|
||||||
|
emojis: Array<CompactEmoji>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -27,8 +27,19 @@ export const searchInput = style({
|
|||||||
export const scrollRoot = style({
|
export const scrollRoot = style({
|
||||||
height: 0,
|
height: 0,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
padding: '0px 12px',
|
|
||||||
});
|
});
|
||||||
|
export const emojiScrollRoot = style([
|
||||||
|
scrollRoot,
|
||||||
|
{
|
||||||
|
paddingTop: '8px',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const iconScrollRoot = style([
|
||||||
|
scrollRoot,
|
||||||
|
{
|
||||||
|
padding: '0px 12px',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const scrollViewport = style({
|
export const scrollViewport = style({
|
||||||
padding: '8px 0px',
|
padding: '8px 0px',
|
||||||
@@ -52,6 +63,7 @@ export const groupName = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '0px 4px',
|
padding: '0px 4px',
|
||||||
|
backgroundColor: cssVarV2.layer.background.overlayPanel,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const groupGrid = style({
|
export const groupGrid = style({
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import clsx from 'clsx';
|
|||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import throttle from 'lodash-es/throttle';
|
import throttle from 'lodash-es/throttle';
|
||||||
import {
|
import {
|
||||||
|
forwardRef,
|
||||||
Fragment,
|
Fragment,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -61,29 +63,39 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
columns?: number;
|
columns?: number;
|
||||||
resizeDebounce?: number;
|
resizeDebounce?: number;
|
||||||
preloadHeight?: number;
|
preloadHeight?: number;
|
||||||
|
|
||||||
|
onStickyGroupChange?: (groupId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Masonry = ({
|
export type MasonryRef = {
|
||||||
items,
|
scrollToGroup: (groupId: string) => void;
|
||||||
gapX = 12,
|
};
|
||||||
gapY = 12,
|
|
||||||
itemWidth = 'stretch',
|
export const Masonry = forwardRef<MasonryRef, MasonryProps>(function Masonry(
|
||||||
itemWidthMin = 100,
|
{
|
||||||
paddingX = 0,
|
items,
|
||||||
paddingY = 0,
|
gapX = 12,
|
||||||
className,
|
gapY = 12,
|
||||||
virtualScroll = false,
|
itemWidth = 'stretch',
|
||||||
locateMode = 'leftTop',
|
itemWidthMin = 100,
|
||||||
groupsGap = 0,
|
paddingX = 0,
|
||||||
groupHeaderGapWithItems = 0,
|
paddingY = 0,
|
||||||
stickyGroupHeader = true,
|
className,
|
||||||
collapsedGroups,
|
virtualScroll = false,
|
||||||
columns,
|
locateMode = 'leftTop',
|
||||||
preloadHeight = 50,
|
groupsGap = 0,
|
||||||
resizeDebounce = 20,
|
groupHeaderGapWithItems = 0,
|
||||||
onGroupCollapse,
|
stickyGroupHeader = true,
|
||||||
...props
|
collapsedGroups,
|
||||||
}: MasonryProps) => {
|
columns,
|
||||||
|
preloadHeight = 50,
|
||||||
|
resizeDebounce = 20,
|
||||||
|
onGroupCollapse,
|
||||||
|
onStickyGroupChange,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [height, setHeight] = useState(0);
|
const [height, setHeight] = useState(0);
|
||||||
const [layoutMap, setLayoutMap] = useState<
|
const [layoutMap, setLayoutMap] = useState<
|
||||||
@@ -212,7 +224,9 @@ export const Masonry = ({
|
|||||||
const scrollY = (e.target as HTMLElement).scrollTop;
|
const scrollY = (e.target as HTMLElement).scrollTop;
|
||||||
updateActiveMap(layoutMap, scrollY);
|
updateActiveMap(layoutMap, scrollY);
|
||||||
if (stickyGroupHeader) {
|
if (stickyGroupHeader) {
|
||||||
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
|
const stickyGroupId = calcSticky({ scrollY, layoutMap });
|
||||||
|
setStickyGroupId(stickyGroupId);
|
||||||
|
onStickyGroupChange?.(stickyGroupId);
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
rootEl.addEventListener('scroll', handler);
|
rootEl.addEventListener('scroll', handler);
|
||||||
@@ -221,7 +235,29 @@ export const Masonry = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return;
|
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 (
|
return (
|
||||||
<Scrollable.Root>
|
<Scrollable.Root>
|
||||||
@@ -312,7 +348,7 @@ export const Masonry = ({
|
|||||||
<Scrollable.Scrollbar className={styles.scrollbar} />
|
<Scrollable.Scrollbar className={styles.scrollbar} />
|
||||||
</Scrollable.Root>
|
</Scrollable.Root>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
type MasonryItemProps = MasonryItem &
|
type MasonryItemProps = MasonryItem &
|
||||||
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> & {
|
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> & {
|
||||||
|
|||||||
@@ -112,12 +112,18 @@ export const calcLayout = (
|
|||||||
const ratioMode = 'ratio' in item;
|
const ratioMode = 'ratio' in item;
|
||||||
const height = ratioMode ? item.ratio * width : item.height;
|
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) {
|
if (ratioMode) {
|
||||||
const minRatio = Math.min(...ratioStack);
|
const minRatio = Math.min(...ratioStack);
|
||||||
const minRatioIndex = ratioStack.indexOf(minRatio);
|
const minRatioIndex = ratioStack.indexOf(minRatio);
|
||||||
const minHeight = heightStack[minRatioIndex];
|
const minHeight = heightStack[minRatioIndex];
|
||||||
const hasGap = heightStack[minRatioIndex] ? gapY : 0;
|
const hasGap = heightStack[minRatioIndex] ? gapY : 0;
|
||||||
const x = minRatioIndex * (width + gapX) + paddingX;
|
const x = minRatioIndex * (width + gapXValue) + paddingX;
|
||||||
const y = finalHeight + minHeight + hasGap;
|
const y = finalHeight + minHeight + hasGap;
|
||||||
|
|
||||||
ratioStack[minRatioIndex] += item.ratio * 10000;
|
ratioStack[minRatioIndex] += item.ratio * 10000;
|
||||||
@@ -133,7 +139,7 @@ export const calcLayout = (
|
|||||||
const minHeight = Math.min(...heightStack);
|
const minHeight = Math.min(...heightStack);
|
||||||
const minHeightIndex = heightStack.indexOf(minHeight);
|
const minHeightIndex = heightStack.indexOf(minHeight);
|
||||||
const hasGap = heightStack[minHeightIndex] ? gapY : 0;
|
const hasGap = heightStack[minHeightIndex] ? gapY : 0;
|
||||||
const x = minHeightIndex * (width + gapX) + paddingX;
|
const x = minHeightIndex * (width + gapXValue) + paddingX;
|
||||||
const y = finalHeight + minHeight + hasGap;
|
const y = finalHeight + minHeight + hasGap;
|
||||||
|
|
||||||
const ratio = height / width;
|
const ratio = height / width;
|
||||||
@@ -193,7 +199,7 @@ export const calcSticky = (options: {
|
|||||||
|
|
||||||
const stickyGroupEntry = groupEntries.find(([_, xywh], index) => {
|
const stickyGroupEntry = groupEntries.find(([_, xywh], index) => {
|
||||||
const next = groupEntries[index + 1];
|
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
|
return stickyGroupEntry
|
||||||
|
|||||||
Reference in New Issue
Block a user