feat(core): edit icon in navigation panel (#13595)

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

- **New Features**
- Rename dialog now edits per-item explorer icons (emoji or custom) and
can skip name-change callbacks. Doc icon picker added to the editor with
localized "Add icon" placeholder and readonly rendering. Icon editor
supports fallbacks, trigger variants, and improved input/test-id wiring.

- **Style**
- Updated icon picker and trigger sizing and placeholder visuals;
title/icon layout adjustments.

- **Chores**
- Explorer icon storage and module added to persist and serve icons
across the app.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-09-22 18:24:11 +08:00
committed by GitHub
parent 93554304e2
commit 195864fc88
25 changed files with 450 additions and 136 deletions
@@ -14,13 +14,19 @@ export const contentRoot = style({
});
export const iconPicker = style({
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
width: 32,
height: 32,
padding: 0,
});
globalStyle(`${iconPicker} span:has(svg)`, {
lineHeight: 0,
});
export const iconNamePickerIcon = style({
flexShrink: 0,
fontSize: 24,
borderRadius: 4,
width: 32,
height: 32,
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
selectors: {
'&[data-icon-type="emoji"]': {
fontSize: 20,
@@ -16,10 +16,10 @@ export default {
} satisfies Meta<typeof IconAndNameEditorMenu>;
export const Basic: StoryFn<IconAndNameEditorMenuProps> = () => {
const [icon, setIcon] = useState<string>('👋');
const [icon, setIcon] = useState<string | undefined>('👋');
const [name, setName] = useState<string>('Hello');
const handleIconChange = useCallback((_: IconType, icon: string) => {
const handleIconChange = useCallback((_?: IconType, icon?: string) => {
setIcon(icon);
}, []);
const handleNameChange = useCallback((name: string) => {
@@ -4,7 +4,7 @@ import clsx from 'clsx';
import { useTheme } from 'next-themes';
import { type ReactNode, useCallback, useState } from 'react';
import { Button } from '../button';
import { Button, type ButtonProps } from '../button';
import Input from '../input';
import { Menu, type MenuProps } from '../menu';
import * as styles from './icon-name-editor.css';
@@ -12,11 +12,11 @@ import * as styles from './icon-name-editor.css';
export type IconType = 'emoji' | 'affine-icon' | 'blob';
export interface IconEditorProps {
iconType: IconType;
icon: string;
iconType?: IconType;
icon?: string;
closeAfterSelect?: boolean;
iconPlaceholder?: ReactNode;
onIconChange?: (type: IconType, icon: string) => void;
onIconChange?: (type?: IconType, icon?: string) => void;
triggerClassName?: string;
}
@@ -24,6 +24,8 @@ export interface IconAndNameEditorContentProps extends IconEditorProps {
name: string;
namePlaceholder?: string;
onNameChange?: (name: string) => void;
onEnter?: () => void;
inputTestId?: string;
}
export interface IconAndNameEditorMenuProps
@@ -33,20 +35,23 @@ export interface IconAndNameEditorMenuProps
onOpenChange?: (open: boolean) => void;
width?: string | number;
skipIfNotChanged?: boolean;
}
const IconRenderer = ({
export const IconRenderer = ({
iconType,
icon,
fallback,
}: {
iconType: IconType;
icon: string;
fallback?: ReactNode;
}) => {
switch (iconType) {
case 'emoji':
return <div>{icon}</div>;
return <div>{icon ?? fallback}</div>;
default:
throw new Error(`Unsupported icon type: ${iconType}`);
return <div>{fallback}</div>;
}
};
@@ -59,9 +64,11 @@ export const IconEditor = ({
onIconChange,
alignOffset,
sideOffset = 4,
triggerVariant,
}: IconEditorProps & {
alignOffset?: number;
sideOffset?: number;
triggerVariant?: ButtonProps['variant'];
}) => {
const [isPickerOpen, setIsPickerOpen] = useState(false);
const { resolvedTheme } = useTheme();
@@ -99,11 +106,18 @@ export const IconEditor = ({
}
>
<Button
variant={triggerVariant}
className={clsx(styles.iconPicker, triggerClassName)}
data-icon-type={iconType}
aria-label={icon ? 'Change Icon' : 'Select Icon'}
title={icon ? 'Change Icon' : 'Select Icon'}
>
{icon ? (
<IconRenderer iconType={iconType} icon={icon} />
{icon && iconType ? (
<IconRenderer
iconType={iconType}
icon={icon}
fallback={iconPlaceholder}
/>
) : (
iconPlaceholder
)}
@@ -115,17 +129,28 @@ export const IconEditor = ({
export const IconAndNameEditorContent = ({
name,
namePlaceholder,
inputTestId,
onNameChange,
onEnter,
...iconEditorProps
}: IconAndNameEditorContentProps) => {
return (
<div className={styles.contentRoot}>
<IconEditor {...iconEditorProps} alignOffset={-4} sideOffset={8} />
<IconEditor
{...iconEditorProps}
alignOffset={-4}
sideOffset={8}
triggerClassName={styles.iconNamePickerIcon}
/>
<Input
placeholder={namePlaceholder}
value={name}
onChange={onNameChange}
onEnter={onEnter}
className={styles.input}
autoSelect
autoFocus
data-testid={inputTestId}
/>
</div>
);
@@ -140,6 +165,10 @@ export const IconAndNameEditorMenu = ({
name: initialName,
onIconChange,
onNameChange,
contentOptions,
iconPlaceholder,
skipIfNotChanged = true,
inputTestId,
...menuProps
}: IconAndNameEditorMenuProps) => {
const [iconType, setIconType] = useState(initialIconType);
@@ -150,7 +179,9 @@ export const IconAndNameEditorMenu = ({
if (iconType !== initialIconType || icon !== initialIcon) {
onIconChange?.(iconType, icon);
}
if (name !== initialName) {
if (skipIfNotChanged) {
if (name !== initialName) onNameChange?.(name);
} else {
onNameChange?.(name);
}
}, [
@@ -162,13 +193,14 @@ export const IconAndNameEditorMenu = ({
name,
onIconChange,
onNameChange,
skipIfNotChanged,
]);
const abort = useCallback(() => {
setIconType(initialIconType);
setIcon(initialIcon);
setName(initialName);
}, [initialIcon, initialIconType, initialName]);
const handleIconChange = useCallback((type: IconType, icon: string) => {
const handleIconChange = useCallback((type?: IconType, icon?: string) => {
setIconType(type);
setIcon(icon);
}, []);
@@ -205,11 +237,8 @@ export const IconAndNameEditorMenu = ({
style: { width },
onPointerDownOutside: commit,
onEscapeKeyDown: abort,
...menuProps.contentOptions,
className: clsx(
styles.menuContent,
menuProps.contentOptions?.className
),
...contentOptions,
className: clsx(styles.menuContent, contentOptions?.className),
}}
{...menuProps}
items={
@@ -217,8 +246,14 @@ export const IconAndNameEditorMenu = ({
iconType={iconType}
icon={icon}
name={name}
iconPlaceholder={iconPlaceholder}
onIconChange={handleIconChange}
onNameChange={handleNameChange}
inputTestId={inputTestId}
onEnter={() => {
commit();
onOpenChange?.(false);
}}
/>
}
/>