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)
This commit is contained in:
EYHN
2024-08-14 10:35:22 +00:00
parent 0504d0b0ff
commit 50948318e0
9 changed files with 108 additions and 24 deletions

View File

@@ -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;

View File

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

View File

@@ -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<GenericExplorerNode, 'operations'>) => {
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<string | null>(null);
@@ -780,6 +791,7 @@ export const ExplorerFolderNodeFolder = ({
onDrop={handleDropOnFolder}
defaultRenaming={defaultRenaming}
renameable
extractEmojiAsIcon={enableEmojiIcon}
reorderable={reorderable}
collapsed={collapsed}
setCollapsed={handleCollapsedChange}

View File

@@ -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',

View File

@@ -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<HTMLDivElement>(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 && (
<div className={styles.iconsContainer}>
<div
data-disabled={disabled}
onClick={handleCollapsedChange}
data-testid="explorer-collapsed-button"
className={styles.collapsedIconContainer}
>
<ArrowDownSmallIcon
className={styles.collapsedIcon}
data-collapsed={collapsed !== false}
/>
</div>
<Icon
className={styles.icon}
draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction}
collapsed={collapsed}
<div className={styles.iconsContainer}>
<div
data-disabled={disabled}
onClick={handleCollapsedChange}
data-testid="explorer-collapsed-button"
className={styles.collapsedIconContainer}
>
<ArrowDownSmallIcon
className={styles.collapsedIcon}
data-collapsed={collapsed !== false}
/>
</div>
)}
{emoji ? (
<div className={styles.emojiIcon}>{emoji}</div>
) : (
Icon && (
<Icon
className={styles.icon}
draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction}
collapsed={collapsed}
/>
)
)}
</div>
{renameable && renaming && (
<RenameModal
open
width={sidebarWidth - 32}
onOpenChange={setRenaming}
onRename={handleRename}
currentName={name ?? ''}
currentName={rawName ?? ''}
>
<div className={styles.itemRenameAnchor} />
</RenameModal>

View File

@@ -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',
});
});

View File

@@ -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,
};
}

View File

@@ -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';