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
@@ -0,0 +1,31 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const docIconPickerTrigger = style({
width: 64,
height: 64,
padding: 2,
selectors: {
'&[data-icon-type="emoji"]': {
fontSize: 60,
lineHeight: 1,
},
},
});
export const placeholder = style({
padding: '4px',
});
export const placeholderContent = style({
display: 'flex',
alignItems: 'center',
gap: 4,
});
export const placeholderContentIcon = style({
color: cssVarV2.icon.secondary,
fontSize: 16,
});
export const placeholderContentText = style({
color: cssVarV2.text.secondary,
fontSize: 12,
});
@@ -0,0 +1,82 @@
import { IconEditor, IconRenderer } from '@affine/component';
import { ExplorerIconService } from '@affine/core/modules/explorer-icon/services/explorer-icon';
import { useI18n } from '@affine/i18n';
import { SmileSolidIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import * as styles from './doc-icon-picker.css';
const TitleContainer = ({
children,
isPlaceholder,
}: {
children: React.ReactNode;
isPlaceholder: boolean;
}) => {
return (
<div
className="doc-icon-container"
style={{
paddingTop: 0,
paddingBottom: 0,
// title container has `padding-top`
transform: isPlaceholder ? 'translateY(80%)' : 'translateY(50%)',
}}
>
{children}
</div>
);
};
export const DocIconPicker = ({
docId,
readonly,
}: {
docId: string;
readonly?: boolean;
}) => {
const t = useI18n();
const explorerIconService = useService(ExplorerIconService);
const icon = useLiveData(explorerIconService.icon$('doc', docId));
const isPlaceholder = !icon?.type || !icon?.icon;
if (readonly) {
return isPlaceholder ? null : (
<div className={styles.docIconPickerTrigger} data-icon-type={icon?.type}>
<IconRenderer iconType={icon.type} icon={icon.icon} />
</div>
);
}
return (
<TitleContainer isPlaceholder={isPlaceholder}>
<IconEditor
iconType={icon?.type}
icon={icon?.icon}
onIconChange={(type, icon) => {
explorerIconService.setIcon({
where: 'doc',
id: docId,
type,
icon,
});
}}
closeAfterSelect={true}
triggerVariant="plain"
triggerClassName={
isPlaceholder ? styles.placeholder : styles.docIconPickerTrigger
}
iconPlaceholder={
<div className={styles.placeholderContent}>
<SmileSolidIcon className={styles.placeholderContentIcon} />
<span className={styles.placeholderContentText}>
{t['com.affine.docIconPicker.placeholder']()}
</span>
</div>
}
/>
</TitleContainer>
);
};
@@ -48,6 +48,7 @@ import {
WorkspacePropertiesTable,
} from '../../components/properties';
import { BiDirectionalLinkPanel } from './bi-directional-link-panel';
import { DocIconPicker } from './doc-icon-picker';
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
import { StarterBar } from './starter-bar';
import * as styles from './styles.css';
@@ -254,6 +255,9 @@ export const BlocksuiteDocEditor = forwardRef<
return (
<>
<div className={styles.affineDocViewport}>
{!BUILD_CONFIG.isMobileEdition ? (
<DocIconPicker docId={page.id} readonly={readonly || shared} />
) : null}
{!isJournal ? (
<LitDocTitle doc={page} ref={onTitleRef} />
) : (