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
@@ -229,6 +229,10 @@ export const NavigationPanelCollectionNode = ({
operations={finalOperations}
dropEffect={handleDropEffectOnCollection}
data-testid={`navigation-panel-collection-${collectionId}`}
explorerIconConfig={{
where: 'collection',
id: collectionId,
}}
>
<NavigationPanelCollectionNodeChildren
collection={collection}
@@ -128,7 +128,7 @@ export const useNavigationPanelCollectionNodeOperations = (
),
},
{
index: 99,
index: 103,
view: (
<MenuItem prefixIcon={<FilterIcon />} onClick={handleShowEdit}>
{t['com.affine.collection.menu.edit']()}
@@ -136,7 +136,7 @@ export const useNavigationPanelCollectionNodeOperations = (
),
},
{
index: 99,
index: 102,
view: (
<MenuItem
prefixIcon={<PlusIcon />}
@@ -147,7 +147,7 @@ export const useNavigationPanelCollectionNodeOperations = (
),
},
{
index: 99,
index: 101,
view: (
<MenuItem
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
@@ -160,7 +160,7 @@ export const useNavigationPanelCollectionNodeOperations = (
),
},
{
index: 99,
index: 100,
view: (
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
{t['com.affine.workbench.tab.page-menu-open']()}
@@ -314,6 +314,10 @@ export const NavigationPanelDocNode = ({
operations={finalOperations}
dropEffect={handleDropEffectOnDoc}
data-testid={`navigation-panel-doc-${docId}`}
explorerIconConfig={{
where: 'doc',
id: docId,
}}
>
{appSettings.showLinkedDocInSidebar ? (
<Guard docId={docId} permission="Doc_Read">
@@ -811,6 +811,7 @@ const NavigationPanelFolderNodeFolder = ({
}
dropEffect={handleDropEffect}
data-testid={`navigation-panel-folder-${node.id}`}
explorerIconConfig={node.id ? { where: 'folder', id: node.id } : null}
>
{children.map(child => (
<NavigationPanelFolderNode
@@ -200,6 +200,10 @@ export const NavigationPanelTagNode = ({
operations={finalOperations}
dropEffect={handleDropEffectOnTag}
data-testid={`navigation-panel-tag-${tagId}`}
explorerIconConfig={{
where: 'tag',
id: tagId,
}}
>
<NavigationPanelTagNodeDocs tag={tagRecord} path={path} />
</NavigationPanelTreeNode>
@@ -1,4 +1,5 @@
import { IconButton } from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
@@ -10,7 +11,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { NavigationPanelTagNode } from '../../nodes/tag';
import { NavigationPanelTreeRoot } from '../../tree';
import { NavigationPanelTreeNodeRenameModal as CreateTagModal } from '../../tree/node';
import { RootEmpty } from './empty';
import * as styles from './styles.css';
@@ -62,11 +62,11 @@ export const NavigationPanelTags = () => {
<AddTagIcon />
</IconButton>
{creating && (
<CreateTagModal
setRenaming={setCreating}
handleRename={handleCreateNewTag}
rawName={t['com.affine.rootAppSidebar.tags.new-tag']()}
className={styles.createModalAnchor}
<RenameModal
open
onOpenChange={setCreating}
onRename={handleCreateNewTag}
currentName={t['com.affine.rootAppSidebar.tags.new-tag']()}
/>
)}
</div>
@@ -4,19 +4,21 @@ import {
type DropTargetDropEvent,
type DropTargetOptions,
type DropTargetTreeInstruction,
IconAndNameEditorMenu,
IconButton,
Menu,
MenuItem,
useDraggable,
useDropTarget,
} from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal';
import { Guard } from '@affine/core/components/guard';
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import type { ExplorerIconType } from '@affine/core/modules/db/schema/schema';
import { ExplorerIconService } from '@affine/core/modules/explorer-icon/services/explorer-icon';
import type { ExplorerType } from '@affine/core/modules/explorer-icon/store/explorer-icon';
import type { DocPermissionActions } from '@affine/core/modules/permissions';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { extractEmojiIcon } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import {
ArrowDownSmallIcon,
@@ -83,6 +85,10 @@ export interface BaseNavigationPanelTreeNodeProps {
[key: `data-${string}`]: any;
}
type ExplorerIconConfig = {
where: ExplorerType;
id: string;
};
interface WebNavigationPanelTreeNodeProps
extends BaseNavigationPanelTreeNodeProps {
renameable?: boolean;
@@ -90,6 +96,8 @@ interface WebNavigationPanelTreeNodeProps
renameableGuard?: { docId: string; action: DocPermissionActions };
defaultRenaming?: boolean;
explorerIconConfig?: ExplorerIconConfig | null;
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
reorderable?: boolean;
dndData?: AffineDNDData;
@@ -105,25 +113,65 @@ export const NavigationPanelTreeNodeRenameModal = ({
setRenaming,
handleRename,
rawName,
explorerIconConfig,
className,
fallbackIcon,
}: {
setRenaming: (renaming: boolean) => void;
handleRename: (newName: string) => void;
rawName: string | undefined;
className?: string;
explorerIconConfig?: ExplorerIconConfig | null;
fallbackIcon?: React.ReactNode;
}) => {
const explorerIconService = useService(ExplorerIconService);
const appSidebarService = useService(AppSidebarService).sidebar;
const sidebarWidth = useLiveData(appSidebarService.width$);
const explorerIcon = useLiveData(
useMemo(
() =>
explorerIconConfig
? explorerIconService.icon$(
explorerIconConfig.where,
explorerIconConfig.id
)
: null,
[explorerIconConfig, explorerIconService]
)
);
const onIconChange = useCallback(
(type?: ExplorerIconType, icon?: string) => {
if (!explorerIconConfig) return;
explorerIconService.setIcon({
where: explorerIconConfig.where,
id: explorerIconConfig.id,
type,
icon,
});
},
[explorerIconConfig, explorerIconService]
);
return (
<RenameModal
<IconAndNameEditorMenu
open
width={sidebarWidth - 32}
onOpenChange={setRenaming}
onRename={handleRename}
currentName={rawName ?? ''}
onIconChange={onIconChange}
onNameChange={handleRename}
name={rawName ?? ''}
iconType={explorerIcon?.type ?? 'emoji'}
icon={explorerIcon?.icon ?? ''}
width={sidebarWidth - 16}
contentOptions={{
sideOffset: 36,
}}
iconPlaceholder={fallbackIcon}
inputTestId="rename-modal-input"
>
<div className={clsx(styles.itemRenameAnchor, className)} />
</RenameModal>
</IconAndNameEditorMenu>
);
};
@@ -140,7 +188,6 @@ export const NavigationPanelTreeNode = ({
onRename,
disabled,
collapsed,
extractEmojiAsIcon,
setCollapsed,
collapsible = true,
canDrop,
@@ -151,10 +198,12 @@ export const NavigationPanelTreeNode = ({
childrenPlaceholder,
linkComponent: LinkComponent = WorkbenchLink,
dndData,
explorerIconConfig,
onDrop,
dropEffect,
...otherProps
}: WebNavigationPanelTreeNodeProps) => {
const explorerIconService = useService(ExplorerIconService);
const t = useI18n();
const cid = useId();
const context = useContext(NavigationPanelTreeContext);
@@ -165,20 +214,19 @@ export const NavigationPanelTreeNode = ({
const [renaming, setRenaming] = useState(defaultRenaming);
const [lastInGroup, setLastInGroup] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const explorerIcon = useLiveData(
useMemo(
() =>
explorerIconConfig
? explorerIconService.icon$(
explorerIconConfig?.where,
explorerIconConfig?.id
)
: null,
[explorerIconConfig, explorerIconService]
)
);
const { emoji, name } = useMemo(() => {
if (!extractEmojiAsIcon || !rawName) {
return {
emoji: null,
name: rawName,
};
}
const { emoji, rest } = extractEmojiIcon(rawName);
return {
emoji,
name: rest,
};
}, [extractEmojiAsIcon, rawName]);
const { dragRef, dragging, CustomDragPreview } = useDraggable<
AffineDNDData & { draggable: { __cid: string } }
>(
@@ -384,6 +432,14 @@ export const NavigationPanelTreeNode = ({
[clickForCollapse, collapsed, collapsible, onClick, setCollapsed]
);
const fallbackIcon = Icon && (
<Icon
draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction}
collapsed={collapsed}
/>
);
const content = (
<div
onClick={handleClick}
@@ -405,19 +461,15 @@ export const NavigationPanelTreeNode = ({
/>
</div>
<div className={styles.iconContainer}>
{emoji ??
(Icon && (
<Icon
draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction}
collapsed={collapsed}
/>
))}
{/* Only emoji icon is supported for now */}
{explorerIcon && explorerIcon.type === 'emoji'
? explorerIcon.icon
: fallbackIcon}
</div>
</div>
<div className={styles.itemMain}>
<div className={styles.itemContent}>{name}</div>
<div className={styles.itemContent}>{rawName}</div>
{postfix}
<div
className={styles.postfix}
@@ -452,6 +504,8 @@ export const NavigationPanelTreeNode = ({
setRenaming={setRenaming}
handleRename={handleRename}
rawName={rawName}
explorerIconConfig={explorerIconConfig}
fallbackIcon={fallbackIcon}
/>
)}
</div>