feat(component): new component to edit icon and name (#12921)

https://github.com/user-attachments/assets/994f7f58-bcbe-4f26-9142-282ffa5025f9



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

- **New Features**
- Introduced an icon and name editor component, allowing users to select
emoji icons and edit names within a menu popover.
- Added an emoji picker for icon selection, supporting theme adaptation.
- **Style**
- Applied new styles for the icon and name editor, including emoji
picker appearance.
- **Documentation**
- Added Storybook stories to showcase and demonstrate the new icon and
name editor component.
- **Chores**
- Added emoji-related dependencies to support emoji selection features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice 2025-06-26 10:39:16 +08:00 committed by GitHub
parent 06f27e8d6a
commit ea7678f17e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 364 additions and 0 deletions

View File

@ -28,6 +28,8 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@blocksuite/icons": "^2.2.12", "@blocksuite/icons": "^2.2.12",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
@ -50,6 +52,7 @@
"check-password-strength": "^3.0.0", "check-password-strength": "^3.0.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"emoji-mart": "^5.6.0",
"foxact": "^0.2.45", "foxact": "^0.2.45",
"jotai": "^2.10.3", "jotai": "^2.10.3",
"lit": "^3.2.1", "lit": "^3.2.1",

View File

@ -12,6 +12,7 @@ export * from './ui/drag-handle';
export * from './ui/editable'; export * from './ui/editable';
export * from './ui/empty'; export * from './ui/empty';
export * from './ui/error-message'; export * from './ui/error-message';
export * from './ui/icon-name-editor';
export * from './ui/input'; export * from './ui/input';
export * from './ui/layout'; export * from './ui/layout';
export * from './ui/loading'; export * from './ui/loading';

View File

@ -0,0 +1,51 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const menuContent = style({
padding: 4,
borderRadius: 8,
});
export const contentRoot = style({
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const iconPicker = style({
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
width: 32,
height: 32,
padding: 0,
flexShrink: 0,
fontSize: 24,
borderRadius: 4,
selectors: {
'&[data-icon-type="emoji"]': {
fontSize: 20,
},
},
});
export const input = style({
height: 32,
borderRadius: 4,
width: 0,
flexGrow: 1,
});
export const emojiPickerPopover = style({
padding: 0,
});
globalStyle('em-emoji-picker', {
vars: {
'--shadow': 'none',
'--font-family': cssVar('fontFamily'),
},
});
globalStyle('em-emoji-picker #root', {
background: 'transparent',
});

View File

@ -0,0 +1,65 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useCallback, useState } from 'react';
import { Button } from '../button';
import { ResizePanel } from '../resize-panel/resize-panel';
import {
IconAndNameEditorMenu,
type IconAndNameEditorMenuProps,
IconEditor,
type IconType,
} from './icon-name-editor';
export default {
title: 'UI/IconAndNameEditorMenu',
component: IconAndNameEditorMenu,
} satisfies Meta<typeof IconAndNameEditorMenu>;
export const Basic: StoryFn<IconAndNameEditorMenuProps> = () => {
const [icon, setIcon] = useState<string>('👋');
const [name, setName] = useState<string>('Hello');
const handleIconChange = useCallback((_: IconType, icon: string) => {
setIcon(icon);
}, []);
const handleNameChange = useCallback((name: string) => {
setName(name);
}, []);
return (
<div>
<p>Icon: {icon}</p>
<p>Name: {name}</p>
<ResizePanel
maxWidth={1200}
maxHeight={800}
width={220}
height={44}
style={{
display: 'flex',
gap: 12,
justifyContent: 'end',
alignItems: 'end',
}}
>
<IconAndNameEditorMenu
iconType="emoji"
icon={icon}
name={name}
onIconChange={handleIconChange}
onNameChange={handleNameChange}
>
<Button>Edit Name and Icon</Button>
</IconAndNameEditorMenu>
<IconEditor
iconType="emoji"
icon={icon}
onIconChange={handleIconChange}
closeAfterSelect
/>
</ResizePanel>
</div>
);
};

View File

@ -0,0 +1,226 @@
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 } from '../button';
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;
closeAfterSelect?: boolean;
iconPlaceholder?: ReactNode;
onIconChange?: (type: IconType, icon: string) => void;
triggerClassName?: string;
}
export interface IconAndNameEditorContentProps extends IconEditorProps {
name: string;
namePlaceholder?: string;
onNameChange?: (name: string) => void;
}
export interface IconAndNameEditorMenuProps
extends Omit<MenuProps, 'items'>,
IconAndNameEditorContentProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
width?: string | number;
}
const IconRenderer = ({
iconType,
icon,
}: {
iconType: IconType;
icon: string;
}) => {
switch (iconType) {
case 'emoji':
return <div>{icon}</div>;
default:
throw new Error(`Unsupported icon type: ${iconType}`);
}
};
export const IconEditor = ({
iconType,
icon,
closeAfterSelect,
iconPlaceholder,
triggerClassName,
onIconChange,
alignOffset,
sideOffset = 4,
}: IconEditorProps & {
alignOffset?: number;
sideOffset?: number;
}) => {
const [isPickerOpen, setIsPickerOpen] = useState(false);
const { resolvedTheme } = useTheme();
const handleEmojiClick = useCallback(
(emoji: any) => {
onIconChange?.('emoji', emoji.native);
if (closeAfterSelect) {
setIsPickerOpen(false);
}
},
[closeAfterSelect, onIconChange]
);
return (
<Menu
rootOptions={{
open: isPickerOpen,
onOpenChange: setIsPickerOpen,
modal: true,
}}
contentOptions={{
side: 'bottom',
sideOffset,
align: 'start',
alignOffset,
className: styles.emojiPickerPopover,
}}
items={
<div onWheel={e => e.stopPropagation()}>
<Picker
data={data}
theme={resolvedTheme}
onEmojiSelect={handleEmojiClick}
/>
</div>
}
>
<Button
className={clsx(styles.iconPicker, triggerClassName)}
data-icon-type={iconType}
>
{icon ? (
<IconRenderer iconType={iconType} icon={icon} />
) : (
iconPlaceholder
)}
</Button>
</Menu>
);
};
export const IconAndNameEditorContent = ({
name,
namePlaceholder,
onNameChange,
...iconEditorProps
}: IconAndNameEditorContentProps) => {
return (
<div className={styles.contentRoot}>
<IconEditor {...iconEditorProps} alignOffset={-4} sideOffset={8} />
<Input
placeholder={namePlaceholder}
value={name}
onChange={onNameChange}
className={styles.input}
/>
</div>
);
};
export const IconAndNameEditorMenu = ({
open,
onOpenChange,
width = 300,
iconType: initialIconType,
icon: initialIcon,
name: initialName,
onIconChange,
onNameChange,
...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 (name !== initialName) {
onNameChange?.(name);
}
}, [
icon,
iconType,
initialIcon,
initialIconType,
initialName,
name,
onIconChange,
onNameChange,
]);
const abort = useCallback(() => {
setIconType(initialIconType);
setIcon(initialIcon);
setName(initialName);
}, [initialIcon, initialIconType, initialName]);
const handleIconChange = useCallback((type: IconType, icon: string) => {
setIconType(type);
setIcon(icon);
}, []);
const handleNameChange = useCallback((name: string) => {
setName(name);
}, []);
const handleMenuOpenChange = useCallback(
(open: boolean) => {
if (open) {
setIconType(initialIconType);
setIcon(initialIcon);
setName(initialName);
}
onOpenChange?.(open);
},
[initialIcon, initialIconType, initialName, onOpenChange]
);
return (
<Menu
rootOptions={{
modal: true,
open,
onOpenChange: handleMenuOpenChange,
...menuProps.rootOptions,
}}
contentOptions={{
side: 'bottom',
sideOffset: 4,
align: 'start',
onClick: e => e.stopPropagation(),
role: 'rename-modal',
style: { width },
onPointerDownOutside: commit,
onEscapeKeyDown: abort,
...menuProps.contentOptions,
className: clsx(
styles.menuContent,
menuProps.contentOptions?.className
),
}}
{...menuProps}
items={
<IconAndNameEditorContent
iconType={iconType}
icon={icon}
name={name}
onIconChange={handleIconChange}
onNameChange={handleNameChange}
/>
}
/>
);
};

View File

@ -0,0 +1 @@
export * from './icon-name-editor';

View File

@ -7,6 +7,10 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { resolveLinkToDoc, toURLSearchParams } from '../utils'; import { resolveLinkToDoc, toURLSearchParams } from '../utils';
vi.mock('@emoji-mart/react', () => ({
Emoji: () => null,
}));
function defineTest( function defineTest(
input: string, input: string,
expected: ReturnType<typeof resolveLinkToDoc> expected: ReturnType<typeof resolveLinkToDoc>

View File

@ -314,6 +314,8 @@ __metadata:
"@blocksuite/affine": "workspace:*" "@blocksuite/affine": "workspace:*"
"@blocksuite/icons": "npm:^2.2.12" "@blocksuite/icons": "npm:^2.2.12"
"@chromatic-com/storybook": "npm:^4.0.0" "@chromatic-com/storybook": "npm:^4.0.0"
"@emoji-mart/data": "npm:^1.2.1"
"@emoji-mart/react": "npm:^1.1.1"
"@emotion/react": "npm:^11.14.0" "@emotion/react": "npm:^11.14.0"
"@emotion/styled": "npm:^11.14.0" "@emotion/styled": "npm:^11.14.0"
"@radix-ui/react-avatar": "npm:^1.1.2" "@radix-ui/react-avatar": "npm:^1.1.2"
@ -344,6 +346,7 @@ __metadata:
check-password-strength: "npm:^3.0.0" check-password-strength: "npm:^3.0.0"
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
dayjs: "npm:^1.11.13" dayjs: "npm:^1.11.13"
emoji-mart: "npm:^5.6.0"
foxact: "npm:^0.2.45" foxact: "npm:^0.2.45"
jotai: "npm:^2.10.3" jotai: "npm:^2.10.3"
lit: "npm:^3.2.1" lit: "npm:^3.2.1"
@ -5438,6 +5441,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@emoji-mart/react@npm:^1.1.1":
version: 1.1.1
resolution: "@emoji-mart/react@npm:1.1.1"
peerDependencies:
emoji-mart: ^5.2
react: ^16.8 || ^17 || ^18
checksum: 10/def4dddaa01ce88c396510d84d1878b881fe0f9c484a836a50e3db784a91ab98edf94816cff503b4d1cab7b00400a01c87e32d19dee2d950f64ef9243f79101e
languageName: node
linkType: hard
"@emotion/babel-plugin@npm:^11.13.5": "@emotion/babel-plugin@npm:^11.13.5":
version: 11.13.5 version: 11.13.5
resolution: "@emotion/babel-plugin@npm:11.13.5" resolution: "@emotion/babel-plugin@npm:11.13.5"