diff --git a/packages/common/infra/src/modules/feature-flag/constant.ts b/packages/common/infra/src/modules/feature-flag/constant.ts index 9d4ea4a4f..62a1679c6 100644 --- a/packages/common/infra/src/modules/feature-flag/constant.ts +++ b/packages/common/infra/src/modules/feature-flag/constant.ts @@ -83,6 +83,14 @@ export const AFFINE_FLAGS = { configurable: isDesktopEnvironment, defaultState: isCanaryBuild, }, + enable_emoji_folder_icon: { + category: 'affine', + displayName: 'Emoji Folder Icon', + description: + 'Once enabled, you can use an emoji as the folder icon. When the first character of the folder name is an emoji, it will be extracted and used as its icon.', + configurable: true, + defaultState: false, + }, } satisfies { [key in string]: FlagInfo }; export type AFFINE_FLAGS = typeof AFFINE_FLAGS; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 70cbbc84e..3300f209d 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -62,6 +62,7 @@ "fractional-indexing": "^3.2.0", "fractional-indexing-jittered": "^0.9.1", "fuse.js": "^7.0.0", + "graphemer": "^1.4.0", "graphql": "^16.8.1", "history": "^5.3.0", "idb": "^8.0.0", diff --git a/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx b/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx index 1a8f06a58..49ff93521 100644 --- a/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx +++ b/packages/frontend/core/src/modules/explorer/views/nodes/folder/index.tsx @@ -34,7 +34,12 @@ import { RemoveFolderIcon, TagsIcon, } from '@blocksuite/icons/rc'; -import { DocsService, useLiveData, useServices } from '@toeverything/infra'; +import { + DocsService, + FeatureFlagService, + useLiveData, + useServices, +} from '@toeverything/infra'; import { difference } from 'lodash-es'; import { useCallback, useMemo, useState } from 'react'; @@ -65,7 +70,9 @@ export const ExplorerFolderNode = ({ | NodeOperation[] | ((type: string, node: FolderNode) => NodeOperation[]); } & Omit) => { - const { organizeService } = useServices({ OrganizeService }); + const { organizeService } = useServices({ + OrganizeService, + }); const node = useLiveData(organizeService.folderTree.folderNode$(nodeId)); const type = useLiveData(node?.type$); const data = useLiveData(node?.data$); @@ -180,15 +187,19 @@ export const ExplorerFolderNodeFolder = ({ node: FolderNode; } & GenericExplorerNode) => { const t = useI18n(); - const { docsService, workbenchService } = useServices({ + const { docsService, workbenchService, featureFlagService } = useServices({ DocsService, WorkbenchService, CompatibleFavoriteItemsAdapter, + FeatureFlagService, }); const openDocsSelector = useSelectDoc(); const openTagsSelector = useSelectTag(); const openCollectionsSelector = useSelectCollection(); const name = useLiveData(node.name$); + const enableEmojiIcon = useLiveData( + featureFlagService.flags.enable_emoji_folder_icon.$ + ); const [collapsed, setCollapsed] = useState(true); const [newFolderId, setNewFolderId] = useState(null); @@ -780,6 +791,7 @@ export const ExplorerFolderNodeFolder = ({ onDrop={handleDropOnFolder} defaultRenaming={defaultRenaming} renameable + extractEmojiAsIcon={enableEmojiIcon} reorderable={reorderable} collapsed={collapsed} setCollapsed={handleCollapsedChange} diff --git a/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts b/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts index d17964d59..d4f259a7f 100644 --- a/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts +++ b/packages/frontend/core/src/modules/explorer/views/tree/node.css.ts @@ -72,6 +72,15 @@ export const icon = style({ color: cssVarV2('icon/primary'), fontSize: '20px', }); +export const emojiIcon = style({ + width: '20px', + height: '20px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + color: cssVarV2('icon/primary'), + fontSize: cssVar('--affine-font-sm'), +}); export const collapsedIconContainer = style({ width: '16px', height: '16px', diff --git a/packages/frontend/core/src/modules/explorer/views/tree/node.tsx b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx index 4bcc2cf64..9b9551ab1 100644 --- a/packages/frontend/core/src/modules/explorer/views/tree/node.tsx +++ b/packages/frontend/core/src/modules/explorer/views/tree/node.tsx @@ -14,6 +14,7 @@ import { RenameModal } from '@affine/component/rename-modal'; import { appSidebarWidthAtom } from '@affine/core/components/app-sidebar/index.jotai'; 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, @@ -59,7 +60,7 @@ export type ExplorerTreeNodeIcon = React.ComponentType<{ export const ExplorerTreeNode = ({ children, icon: Icon, - name, + name: rawName, onClick, to, active, @@ -68,6 +69,7 @@ export const ExplorerTreeNode = ({ onRename, disabled, collapsed, + extractEmojiAsIcon, setCollapsed, canDrop, reorderable = true, @@ -87,6 +89,7 @@ export const ExplorerTreeNode = ({ active?: boolean; reorderable?: boolean; defaultRenaming?: boolean; + extractEmojiAsIcon?: boolean; collapsed: boolean; setCollapsed: (collapsed: boolean) => void; renameable?: boolean; @@ -117,6 +120,19 @@ export const ExplorerTreeNode = ({ const [renaming, setRenaming] = useState(defaultRenaming); const [lastInGroup, setLastInGroup] = useState(false); const rootRef = useRef(null); + 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 } } >( @@ -299,34 +315,38 @@ export const ExplorerTreeNode = ({ data-active={active} data-disabled={disabled} > - {Icon && ( -
-
- -
- +
+
- )} + {emoji ? ( +
{emoji}
+ ) : ( + Icon && ( + + ) + )} +
{renameable && renaming && (
diff --git a/packages/frontend/core/src/utils/__tests__/extract-emoji-icon.spec.ts b/packages/frontend/core/src/utils/__tests__/extract-emoji-icon.spec.ts new file mode 100644 index 000000000..3828b7e09 --- /dev/null +++ b/packages/frontend/core/src/utils/__tests__/extract-emoji-icon.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest'; + +import { extractEmojiIcon } from '../extract-emoji-icon'; + +test('extract-emoji-icon', () => { + expect(extractEmojiIcon('👨🏻‍❤️‍💋‍👨🏻123')).toEqual({ + emoji: '👨🏻‍❤️‍💋‍👨🏻', + rest: '123', + }); + + expect(extractEmojiIcon('❤️123')).toEqual({ + emoji: null, + rest: '❤️123', + }); +}); diff --git a/packages/frontend/core/src/utils/extract-emoji-icon.ts b/packages/frontend/core/src/utils/extract-emoji-icon.ts new file mode 100644 index 000000000..8fb7b1a1b --- /dev/null +++ b/packages/frontend/core/src/utils/extract-emoji-icon.ts @@ -0,0 +1,17 @@ +import Graphemer from 'graphemer'; + +export function extractEmojiIcon(text: string) { + const isStartsWithEmoji = /^(\p{Emoji_Presentation})/u.test(text); + if (isStartsWithEmoji) { + // emoji like "👨🏻‍❤️‍💋‍👨🏻" are combined. Graphemer can handle these. + const emojiEnd = Graphemer.nextBreak(text, 0); + return { + emoji: text.substring(0, emojiEnd), + rest: text.substring(emojiEnd), + }; + } + return { + emoji: null, + rest: text, + }; +} diff --git a/packages/frontend/core/src/utils/index.ts b/packages/frontend/core/src/utils/index.ts index 464497ff6..01feef074 100644 --- a/packages/frontend/core/src/utils/index.ts +++ b/packages/frontend/core/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './create-emotion-cache'; export * from './event'; +export * from './extract-emoji-icon'; export * from './fractional-indexing'; export * from './popup'; export * from './string2color'; diff --git a/yarn.lock b/yarn.lock index 7d24675b2..e117a749f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -444,6 +444,7 @@ __metadata: fractional-indexing: "npm:^3.2.0" fractional-indexing-jittered: "npm:^0.9.1" fuse.js: "npm:^7.0.0" + graphemer: "npm:^1.4.0" graphql: "npm:^16.8.1" history: "npm:^5.3.0" idb: "npm:^8.0.0"