From ea7678f17e63c6308b7e49639867815d5c788ab9 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Thu, 26 Jun 2025 10:39:16 +0800 Subject: [PATCH] feat(component): new component to edit icon and name (#12921) https://github.com/user-attachments/assets/994f7f58-bcbe-4f26-9142-282ffa5025f9 ## 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. --- packages/frontend/component/package.json | 3 + packages/frontend/component/src/index.ts | 1 + .../icon-name-editor/icon-name-editor.css.ts | 51 ++++ .../icon-name-editor.stories.tsx | 65 +++++ .../ui/icon-name-editor/icon-name-editor.tsx | 226 ++++++++++++++++++ .../src/ui/icon-name-editor/index.ts | 1 + .../navigation/__tests__/utils.spec.ts | 4 + yarn.lock | 13 + 8 files changed, 364 insertions(+) create mode 100644 packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.css.ts create mode 100644 packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.stories.tsx create mode 100644 packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.tsx create mode 100644 packages/frontend/component/src/ui/icon-name-editor/index.ts diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 69c123babc..fde832de66 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -28,6 +28,8 @@ "@atlaskit/pragmatic-drag-and-drop": "^1.4.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@blocksuite/icons": "^2.2.12", + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@radix-ui/react-avatar": "^1.1.2", @@ -50,6 +52,7 @@ "check-password-strength": "^3.0.0", "clsx": "^2.1.1", "dayjs": "^1.11.13", + "emoji-mart": "^5.6.0", "foxact": "^0.2.45", "jotai": "^2.10.3", "lit": "^3.2.1", diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index 54047ee603..6b20735072 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -12,6 +12,7 @@ export * from './ui/drag-handle'; export * from './ui/editable'; export * from './ui/empty'; export * from './ui/error-message'; +export * from './ui/icon-name-editor'; export * from './ui/input'; export * from './ui/layout'; export * from './ui/loading'; diff --git a/packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.css.ts b/packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.css.ts new file mode 100644 index 0000000000..1446acdc04 --- /dev/null +++ b/packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.css.ts @@ -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', +}); diff --git a/packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.stories.tsx b/packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.stories.tsx new file mode 100644 index 0000000000..514b92f58a --- /dev/null +++ b/packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.stories.tsx @@ -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; + +export const Basic: StoryFn = () => { + const [icon, setIcon] = useState('👋'); + const [name, setName] = useState('Hello'); + + const handleIconChange = useCallback((_: IconType, icon: string) => { + setIcon(icon); + }, []); + const handleNameChange = useCallback((name: string) => { + setName(name); + }, []); + + return ( +
+

Icon: {icon}

+

Name: {name}

+ + + + + + + + +
+ ); +}; diff --git a/packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.tsx b/packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.tsx new file mode 100644 index 0000000000..f477a933fe --- /dev/null +++ b/packages/frontend/component/src/ui/icon-name-editor/icon-name-editor.tsx @@ -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, + IconAndNameEditorContentProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + + width?: string | number; +} + +const IconRenderer = ({ + iconType, + icon, +}: { + iconType: IconType; + icon: string; +}) => { + switch (iconType) { + case 'emoji': + return
{icon}
; + 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 ( + e.stopPropagation()}> + + + } + > + + + ); +}; + +export const IconAndNameEditorContent = ({ + name, + namePlaceholder, + onNameChange, + ...iconEditorProps +}: IconAndNameEditorContentProps) => { + return ( +
+ + +
+ ); +}; + +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 ( + e.stopPropagation(), + role: 'rename-modal', + style: { width }, + onPointerDownOutside: commit, + onEscapeKeyDown: abort, + ...menuProps.contentOptions, + className: clsx( + styles.menuContent, + menuProps.contentOptions?.className + ), + }} + {...menuProps} + items={ + + } + /> + ); +}; diff --git a/packages/frontend/component/src/ui/icon-name-editor/index.ts b/packages/frontend/component/src/ui/icon-name-editor/index.ts new file mode 100644 index 0000000000..a24ceda4fa --- /dev/null +++ b/packages/frontend/component/src/ui/icon-name-editor/index.ts @@ -0,0 +1 @@ +export * from './icon-name-editor'; diff --git a/packages/frontend/core/src/modules/navigation/__tests__/utils.spec.ts b/packages/frontend/core/src/modules/navigation/__tests__/utils.spec.ts index 758eb214f0..cf91076eb6 100644 --- a/packages/frontend/core/src/modules/navigation/__tests__/utils.spec.ts +++ b/packages/frontend/core/src/modules/navigation/__tests__/utils.spec.ts @@ -7,6 +7,10 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { resolveLinkToDoc, toURLSearchParams } from '../utils'; +vi.mock('@emoji-mart/react', () => ({ + Emoji: () => null, +})); + function defineTest( input: string, expected: ReturnType diff --git a/yarn.lock b/yarn.lock index 303c41a722..29b1cc58dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -314,6 +314,8 @@ __metadata: "@blocksuite/affine": "workspace:*" "@blocksuite/icons": "npm:^2.2.12" "@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/styled": "npm:^11.14.0" "@radix-ui/react-avatar": "npm:^1.1.2" @@ -344,6 +346,7 @@ __metadata: check-password-strength: "npm:^3.0.0" clsx: "npm:^2.1.1" dayjs: "npm:^1.11.13" + emoji-mart: "npm:^5.6.0" foxact: "npm:^0.2.45" jotai: "npm:^2.10.3" lit: "npm:^3.2.1" @@ -5438,6 +5441,16 @@ __metadata: languageName: node 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": version: 11.13.5 resolution: "@emotion/babel-plugin@npm:11.13.5"