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 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"