mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 22:09:08 +08:00
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:
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './icon-name-editor';
|
||||
Reference in New Issue
Block a user