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
@@ -46,6 +46,14 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
collectionId: f.string().primaryKey(),
index: f.string(),
},
explorerIcon: {
/**
* ${doc|collection|folder|tag}:${id}
*/
id: f.string().primaryKey(),
type: f.enum('emoji', 'affine-icon', 'blob'),
icon: f.string(),
},
} as const satisfies DBSchemaBuilder;
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
@@ -53,6 +61,10 @@ export type DocProperties = ORMEntity<AFFiNEWorkspaceDbSchema['docProperties']>;
export type DocCustomPropertyInfo = ORMEntity<
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
>;
export type ExplorerIcon = ORMEntity<AFFiNEWorkspaceDbSchema['explorerIcon']>;
export type ExplorerIconType = ORMEntity<
AFFiNEWorkspaceDbSchema['explorerIcon']
>['type'];
export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
favorite: {
@@ -1,7 +1,7 @@
import { type Framework } from '@toeverything/infra';
import { DocsService } from '../doc';
import { FeatureFlagService } from '../feature-flag';
import { ExplorerIconService } from '../explorer-icon/services/explorer-icon';
import { I18nService } from '../i18n';
import { JournalService } from '../journal';
import { WorkspaceScope } from '../workspace';
@@ -15,7 +15,7 @@ export function configureDocDisplayMetaModule(framework: Framework) {
.service(DocDisplayMetaService, [
JournalService,
DocsService,
FeatureFlagService,
I18nService,
ExplorerIconService,
]);
}
@@ -1,4 +1,3 @@
import { extractEmojiIcon } from '@affine/core/utils';
import { i18nTime } from '@affine/i18n';
import {
AliasIcon as LitAliasIcon,
@@ -27,7 +26,7 @@ import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import type { DocRecord, DocsService } from '../../doc';
import type { FeatureFlagService } from '../../feature-flag';
import type { ExplorerIconService } from '../../explorer-icon/services/explorer-icon';
import type { I18nService } from '../../i18n';
import type { JournalService } from '../../journal';
@@ -87,8 +86,8 @@ export class DocDisplayMetaService extends Service {
constructor(
private readonly journalService: JournalService,
private readonly docsService: DocsService,
private readonly featureFlagService: FeatureFlagService,
private readonly i18nService: I18nService
private readonly i18nService: I18nService,
private readonly explorerIconService: ExplorerIconService
) {
super();
}
@@ -128,30 +127,30 @@ export class DocDisplayMetaService extends Service {
const iconSet = icons[options?.type ?? 'rc'];
return LiveData.computed(get => {
const enableEmojiIcon =
get(this.featureFlagService.flags.enable_emoji_doc_icon.$) &&
options?.enableEmojiIcon !== false;
const enableEmojiIcon = options?.enableEmojiIcon !== false;
const doc = get(this.docsService.list.doc$(docId));
const referenced = !!options?.reference;
const titleAlias = referenced ? options?.title : undefined;
const originalTitle = doc ? get(doc.title$) : '';
// const originalTitle = doc ? get(doc.title$) : '';
// link to journal doc
const journalDateString = get(this.journalService.journalDate$(docId));
const journalIcon = journalDateString
? this.getJournalIcon(journalDateString, options)
: undefined;
const journalTitle = journalDateString
? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
: undefined;
const title = titleAlias ?? journalTitle ?? originalTitle;
// const journalTitle = journalDateString
// ? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
// : undefined;
// const title = titleAlias ?? journalTitle ?? originalTitle;
const mode = doc ? get(doc.primaryMode$) : undefined;
const finalMode = options?.mode ?? mode ?? 'page';
const referenceToNode = !!(referenced && options.referenceToNode);
// emoji title
if (enableEmojiIcon && title) {
const { emoji } = extractEmojiIcon(title);
if (emoji) return () => emoji;
if (enableEmojiIcon) {
// const { emoji } = extractEmojiIcon(title);
// if (emoji) return () => emoji;
const icon = get(this.explorerIconService.icon$('doc', docId));
if (icon && icon.type === 'emoji') return () => icon.icon;
}
// title alias
@@ -176,10 +175,6 @@ export class DocDisplayMetaService extends Service {
title$(docId: string, options?: DocDisplayTitleOptions) {
return LiveData.computed(get => {
const enableEmojiIcon =
get(this.featureFlagService.flags.enable_emoji_doc_icon.$) &&
options?.enableEmojiIcon !== false;
const lng = get(this.i18nService.i18n.currentLanguageKey$);
const doc = get(this.docsService.list.doc$(docId));
const referenced = !!options?.reference;
@@ -190,20 +185,17 @@ export class DocDisplayMetaService extends Service {
const journalTitle = journalDateString
? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
: undefined;
const title = titleAlias ?? journalTitle ?? originalTitle;
// const title = titleAlias ?? journalTitle ?? originalTitle;
// emoji title
if (enableEmojiIcon && title) {
const { rest } = extractEmojiIcon(title);
if (rest) return rest;
// When the title has only one emoji character,
// if it's a journal document, the date should be displayed.
const journalDateString = get(this.journalService.journalDate$(docId));
if (journalDateString) {
return i18nTime(journalDateString, { absolute: { accuracy: 'day' } });
}
}
// if (enableEmojiIcon && title) {
// // When the title has only one emoji character,
// // if it's a journal document, the date should be displayed.
// const journalDateString = get(this.journalService.journalDate$(docId));
// if (journalDateString) {
// return i18nTime(journalDateString, { absolute: { accuracy: 'day' } });
// }
// }
// title alias
if (titleAlias) return titleAlias;
@@ -0,0 +1,13 @@
import type { Framework } from '@toeverything/infra';
import { WorkspaceDBService } from '../db';
import { WorkspaceScope } from '../workspace';
import { ExplorerIconService } from './services/explorer-icon';
import { ExplorerIconStore } from './store/explorer-icon';
export function configureExplorerIconModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.store(ExplorerIconStore, [WorkspaceDBService])
.service(ExplorerIconService, [ExplorerIconStore]);
}
@@ -0,0 +1,21 @@
import { LiveData, Service } from '@toeverything/infra';
import type { ExplorerIconStore, ExplorerType } from '../store/explorer-icon';
export class ExplorerIconService extends Service {
constructor(private readonly store: ExplorerIconStore) {
super();
}
getIcon(type: ExplorerType, id: string) {
return this.store.getIcon(type, id);
}
setIcon(options: Parameters<ExplorerIconStore['setIcon']>[0]) {
return this.store.setIcon(options);
}
icon$(type: ExplorerType, id: string) {
return LiveData.from(this.store.watchIcon(type, id), null);
}
}
@@ -0,0 +1,39 @@
import { Store } from '@toeverything/infra';
import type { WorkspaceDBService } from '../../db';
import type { ExplorerIconType } from '../../db/schema/schema';
export type ExplorerType = 'doc' | 'collection' | 'folder' | 'tag';
export class ExplorerIconStore extends Store {
constructor(private readonly dbService: WorkspaceDBService) {
super();
}
watchIcon(type: ExplorerType, id: string) {
return this.dbService.db.explorerIcon.get$(`${type}:${id}`);
}
getIcon(type: ExplorerType, id: string) {
return this.dbService.db.explorerIcon.get(`${type}:${id}`);
}
setIcon(options: {
where: ExplorerType;
id: string;
type?: ExplorerIconType;
icon?: string;
}) {
const { where, id, type, icon } = options;
// remove icon
if (!type || !icon) {
return this.dbService.db.explorerIcon.delete(`${where}:${id}`);
}
// upsert icon
return this.dbService.db.explorerIcon.create({
id: `${where}:${id}`,
type,
icon,
});
}
}
@@ -27,6 +27,7 @@ import { configureDocSummaryModule } from './doc-summary';
import { configureDocsSearchModule } from './docs-search';
import { configureEditorModule } from './editor';
import { configureEditorSettingModule } from './editor-setting';
import { configureExplorerIconModule } from './explorer-icon';
import { configureFavoriteModule } from './favorite';
import { configureFeatureFlagModule } from './feature-flag';
import { configureGlobalContextModule } from './global-context';
@@ -85,6 +86,7 @@ export function configureCommonModules(framework: Framework) {
configureTelemetryModule(framework);
configurePDFModule(framework);
configurePeekViewModule(framework);
configureExplorerIconModule(framework);
configureDocDisplayMetaModule(framework);
configureQuickSearchModule(framework);
configureDocsSearchModule(framework);