mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
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:
@@ -15,6 +15,7 @@ export const contentRoot = style({
|
||||
|
||||
export const iconPicker = style({
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
});
|
||||
globalStyle(`${iconPicker} span:has(svg)`, {
|
||||
lineHeight: 0,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './icon-picker';
|
||||
export * from './renderer';
|
||||
export * from './type';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
20
packages/frontend/component/src/ui/icon-picker/type.ts
Normal file
20
packages/frontend/component/src/ui/icon-picker/type.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user