diff --git a/packages/common/infra/src/modules/feature-flag/constant.ts b/packages/common/infra/src/modules/feature-flag/constant.ts index 9d4ea4a4f1..62a1679c6e 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 70cbbc84ed..3300f209d2 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 1a8f06a584..49ff93521c 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 d17964d59a..d4f259a7f8 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 4bcc2cf642..9b9551ab15 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 0000000000..3828b7e09a --- /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 0000000000..8fb7b1a1b3 --- /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 464497ff62..01feef0741 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 7d24675b23..e117a749ff 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"