feat(core): replace emoji-mart with affine icon picker (#13644)

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

- New Features
  - Unified icon picker with consistent rendering across the app.
  - Picker can auto-close after selection.
  - “Remove” now clears the icon selection.

- Refactor
- Icon handling consolidated across editors, navigation, and document
titles for consistent behavior.
  - Picker now opens on the Emoji panel by default.

- Style
  - Adjusted line-height and selectors for icon picker visuals.

- Chores
  - Removed unused emoji-mart dependencies.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-09-26 14:41:29 +08:00
committed by GitHub
parent c540400496
commit d272c4342d
16 changed files with 121 additions and 138 deletions

View File

@@ -15,6 +15,7 @@ export const contentRoot = style({
export const iconPicker = style({
padding: 0,
lineHeight: 1,
});
globalStyle(`${iconPicker} span:has(svg)`, {
lineHeight: 0,

View File

@@ -2,12 +2,12 @@ import type { Meta, StoryFn } from '@storybook/react';
import { useCallback, useState } from 'react';
import { Button } from '../button';
import { type IconData, IconType } from '../icon-picker';
import { ResizePanel } from '../resize-panel/resize-panel';
import {
IconAndNameEditorMenu,
type IconAndNameEditorMenuProps,
IconEditor,
type IconType,
} from './icon-name-editor';
export default {
@@ -16,10 +16,13 @@ export default {
} satisfies Meta<typeof IconAndNameEditorMenu>;
export const Basic: StoryFn<IconAndNameEditorMenuProps> = () => {
const [icon, setIcon] = useState<string | undefined>('👋');
const [icon, setIcon] = useState<IconData | undefined>({
type: IconType.Emoji,
unicode: '👋',
});
const [name, setName] = useState<string>('Hello');
const handleIconChange = useCallback((_?: IconType, icon?: string) => {
const handleIconChange = useCallback((icon?: IconData) => {
setIcon(icon);
}, []);
const handleNameChange = useCallback((name: string) => {
@@ -28,7 +31,7 @@ export const Basic: StoryFn<IconAndNameEditorMenuProps> = () => {
return (
<div>
<p>Icon: {icon}</p>
<p>Icon: {JSON.stringify(icon)}</p>
<p>Name: {name}</p>
<ResizePanel
@@ -44,17 +47,16 @@ export const Basic: StoryFn<IconAndNameEditorMenuProps> = () => {
}}
>
<IconAndNameEditorMenu
iconType="emoji"
icon={icon}
name={name}
onIconChange={handleIconChange}
onNameChange={handleNameChange}
closeAfterSelect
>
<Button>Edit Name and Icon</Button>
</IconAndNameEditorMenu>
<IconEditor
iconType="emoji"
icon={icon}
onIconChange={handleIconChange}
closeAfterSelect

View File

@@ -1,22 +1,18 @@
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import clsx from 'clsx';
import { useTheme } from 'next-themes';
import { type ReactNode, useCallback, useState } from 'react';
import { Button, type ButtonProps } from '../button';
import { type IconData, IconPicker } from '../icon-picker';
import { IconRenderer } from '../icon-picker/renderer';
import Input from '../input';
import { Menu, type MenuProps } from '../menu';
import * as styles from './icon-name-editor.css';
export type IconType = 'emoji' | 'affine-icon' | 'blob';
export interface IconEditorProps {
iconType?: IconType;
icon?: string;
icon?: IconData;
closeAfterSelect?: boolean;
iconPlaceholder?: ReactNode;
onIconChange?: (type?: IconType, icon?: string) => void;
onIconChange?: (data?: IconData) => void;
triggerClassName?: string;
}
@@ -38,25 +34,7 @@ export interface IconAndNameEditorMenuProps
skipIfNotChanged?: boolean;
}
export const IconRenderer = ({
iconType,
icon,
fallback,
}: {
iconType: IconType;
icon: string;
fallback?: ReactNode;
}) => {
switch (iconType) {
case 'emoji':
return <div>{icon ?? fallback}</div>;
default:
return <div>{fallback}</div>;
}
};
export const IconEditor = ({
iconType,
icon,
closeAfterSelect,
iconPlaceholder,
@@ -71,16 +49,17 @@ export const IconEditor = ({
triggerVariant?: ButtonProps['variant'];
}) => {
const [isPickerOpen, setIsPickerOpen] = useState(false);
const { resolvedTheme } = useTheme();
const handleEmojiClick = useCallback(
(emoji: any) => {
onIconChange?.('emoji', emoji.native);
const handleSelect = useCallback(
(data?: IconData) => {
onIconChange?.(data);
if (closeAfterSelect) {
setIsPickerOpen(false);
}
},
[closeAfterSelect, onIconChange]
);
return (
<Menu
rootOptions={{
@@ -97,30 +76,18 @@ export const IconEditor = ({
}}
items={
<div onWheel={e => e.stopPropagation()}>
<Picker
data={data}
theme={resolvedTheme}
onEmojiSelect={handleEmojiClick}
/>
<IconPicker onSelect={handleSelect} />
</div>
}
>
<Button
variant={triggerVariant}
className={clsx(styles.iconPicker, triggerClassName)}
data-icon-type={iconType}
data-icon-type={icon?.type}
aria-label={icon ? 'Change Icon' : 'Select Icon'}
title={icon ? 'Change Icon' : 'Select Icon'}
>
{icon && iconType ? (
<IconRenderer
iconType={iconType}
icon={icon}
fallback={iconPlaceholder}
/>
) : (
iconPlaceholder
)}
<IconRenderer data={icon} fallback={iconPlaceholder} />
</Button>
</Menu>
);
@@ -160,7 +127,6 @@ export const IconAndNameEditorMenu = ({
open,
onOpenChange,
width = 300,
iconType: initialIconType,
icon: initialIcon,
name: initialName,
onIconChange,
@@ -169,15 +135,15 @@ export const IconAndNameEditorMenu = ({
iconPlaceholder,
skipIfNotChanged = true,
inputTestId,
closeAfterSelect,
...menuProps
}: IconAndNameEditorMenuProps) => {
const [iconType, setIconType] = useState(initialIconType);
const [icon, setIcon] = useState(initialIcon);
const [name, setName] = useState(initialName);
const commit = useCallback(() => {
if (iconType !== initialIconType || icon !== initialIcon) {
onIconChange?.(iconType, icon);
if (icon !== initialIcon) {
onIconChange?.(icon);
}
if (skipIfNotChanged) {
if (name !== initialName) onNameChange?.(name);
@@ -186,9 +152,7 @@ export const IconAndNameEditorMenu = ({
}
}, [
icon,
iconType,
initialIcon,
initialIconType,
initialName,
name,
onIconChange,
@@ -196,13 +160,11 @@ export const IconAndNameEditorMenu = ({
skipIfNotChanged,
]);
const abort = useCallback(() => {
setIconType(initialIconType);
setIcon(initialIcon);
setName(initialName);
}, [initialIcon, initialIconType, initialName]);
const handleIconChange = useCallback((type?: IconType, icon?: string) => {
setIconType(type);
setIcon(icon);
}, [initialIcon, initialName]);
const handleIconChange = useCallback((data?: IconData) => {
setIcon(data);
}, []);
const handleNameChange = useCallback((name: string) => {
setName(name);
@@ -210,13 +172,12 @@ export const IconAndNameEditorMenu = ({
const handleMenuOpenChange = useCallback(
(open: boolean) => {
if (open) {
setIconType(initialIconType);
setIcon(initialIcon);
setName(initialName);
}
onOpenChange?.(open);
},
[initialIcon, initialIconType, initialName, onOpenChange]
[initialIcon, initialName, onOpenChange]
);
return (
@@ -243,10 +204,10 @@ export const IconAndNameEditorMenu = ({
{...menuProps}
items={
<IconAndNameEditorContent
iconType={iconType}
icon={icon}
name={name}
iconPlaceholder={iconPlaceholder}
closeAfterSelect={closeAfterSelect}
onIconChange={handleIconChange}
onNameChange={handleNameChange}
inputTestId={inputTestId}

View File

@@ -7,6 +7,7 @@ import { RadioGroup, type RadioItem } from '../radio';
import * as styles from './icon-picker.css';
import { AffineIconPicker } from './picker/affine-icon/affine-icon-picker';
import { EmojiPicker } from './picker/emoji/emoji-picker';
import { type IconData, IconType } from './type';
const panels: Array<RadioItem> = [
{ value: 'Emoji', className: styles.headerNavItem },
@@ -16,17 +17,11 @@ const panels: Array<RadioItem> = [
export const IconPicker = ({
className,
style,
}: HTMLAttributes<HTMLDivElement> & {
onSelect?: (
type: 'emoji' | 'affine-icon',
data: { icon?: string; color?: string }
) => void;
onSelect,
}: Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> & {
onSelect?: (data?: IconData) => void;
}) => {
const [activePanel, setActivePanel] = useState<string>('Icons');
// const ActivePanel = panels.find(
// panel => panel.value === activePanel
// )?.component;
const [activePanel, setActivePanel] = useState<string>('Emoji');
return (
<div className={clsx(styles.container, className)} style={{ ...style }}>
@@ -53,7 +48,7 @@ export const IconPicker = ({
<Button
variant="plain"
style={{ color: cssVarV2.text.secondary, fontWeight: 500 }}
onClick={() => void 0}
onClick={() => onSelect?.()}
>
Remove
</Button>
@@ -61,9 +56,17 @@ export const IconPicker = ({
</header>
<main className={styles.main}>
{activePanel === 'Emoji' ? (
<EmojiPicker />
<EmojiPicker
onSelect={emoji => {
onSelect?.({ type: IconType.Emoji, unicode: emoji });
}}
/>
) : activePanel === 'Icons' ? (
<AffineIconPicker />
<AffineIconPicker
onSelect={(icon, color) => {
onSelect?.({ type: IconType.AffineIcon, name: icon, color });
}}
/>
) : null}
</main>
</div>

View File

@@ -1 +1,3 @@
export * from './icon-picker';
export * from './renderer';
export * from './type';

View File

@@ -1,18 +1,29 @@
import type { ReactNode } from 'react';
import { AffineIconRenderer } from './renderer/affine-icon';
import { type IconData, IconType } from './type';
export const IconRenderer = ({
iconType,
icon,
data,
fallback,
}: {
iconType: 'emoji' | 'affine-icon';
icon: string;
data?: IconData;
fallback?: ReactNode;
}) => {
if (iconType === 'emoji') {
return icon;
}
if (iconType === 'affine-icon') {
return <AffineIconRenderer name={icon} />;
if (!data) {
return fallback ?? null;
}
return null;
if (data.type === IconType.Emoji && data.unicode) {
return data.unicode;
}
if (data.type === IconType.AffineIcon && data.name) {
return <AffineIconRenderer name={data.name} color={data.color} />;
}
if (data.type === IconType.Blob) {
// Not supported yet
return null;
}
return fallback ?? null;
};

View File

@@ -0,0 +1,20 @@
export enum IconType {
Emoji = 'emoji',
AffineIcon = 'affine-icon',
Blob = 'blob',
}
export type IconData =
| {
type: IconType.Emoji;
unicode: string;
}
| {
type: IconType.AffineIcon;
name: string;
color: string;
}
| {
type: IconType.Blob;
blob: Blob;
};