From 50948318e02b38b0d458d45359c68d59a7944ad9 Mon Sep 17 00:00:00 2001 From: EYHN Date: Wed, 14 Aug 2024 10:35:22 +0000 Subject: [PATCH] feat(core): use emoji as folder icon (#7842) ![CleanShot 2024-08-12 at 22.33.42.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/c0c1a196-be29-4e45-a28d-87a55d80fc9d.png) --- .../src/modules/feature-flag/constant.ts | 8 +++ packages/frontend/core/package.json | 1 + .../explorer/views/nodes/folder/index.tsx | 18 +++++- .../modules/explorer/views/tree/node.css.ts | 9 +++ .../src/modules/explorer/views/tree/node.tsx | 62 ++++++++++++------- .../__tests__/extract-emoji-icon.spec.ts | 15 +++++ .../core/src/utils/extract-emoji-icon.ts | 17 +++++ packages/frontend/core/src/utils/index.ts | 1 + yarn.lock | 1 + 9 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 packages/frontend/core/src/utils/__tests__/extract-emoji-icon.spec.ts create mode 100644 packages/frontend/core/src/utils/extract-emoji-icon.ts 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"