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, configurable: isDesktopEnvironment,
defaultState: isCanaryBuild, 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 }; } satisfies { [key in string]: FlagInfo };
export type AFFINE_FLAGS = typeof AFFINE_FLAGS; export type AFFINE_FLAGS = typeof AFFINE_FLAGS;

View File

@@ -62,6 +62,7 @@
"fractional-indexing": "^3.2.0", "fractional-indexing": "^3.2.0",
"fractional-indexing-jittered": "^0.9.1", "fractional-indexing-jittered": "^0.9.1",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"graphemer": "^1.4.0",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"history": "^5.3.0", "history": "^5.3.0",
"idb": "^8.0.0", "idb": "^8.0.0",

View File

@@ -34,7 +34,12 @@ import {
RemoveFolderIcon, RemoveFolderIcon,
TagsIcon, TagsIcon,
} from '@blocksuite/icons/rc'; } 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 { difference } from 'lodash-es';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
@@ -65,7 +70,9 @@ export const ExplorerFolderNode = ({
| NodeOperation[] | NodeOperation[]
| ((type: string, node: FolderNode) => NodeOperation[]); | ((type: string, node: FolderNode) => NodeOperation[]);
} & Omit<GenericExplorerNode, 'operations'>) => { } & Omit<GenericExplorerNode, 'operations'>) => {
const { organizeService } = useServices({ OrganizeService }); const { organizeService } = useServices({
OrganizeService,
});
const node = useLiveData(organizeService.folderTree.folderNode$(nodeId)); const node = useLiveData(organizeService.folderTree.folderNode$(nodeId));
const type = useLiveData(node?.type$); const type = useLiveData(node?.type$);
const data = useLiveData(node?.data$); const data = useLiveData(node?.data$);
@@ -180,15 +187,19 @@ export const ExplorerFolderNodeFolder = ({
node: FolderNode; node: FolderNode;
} & GenericExplorerNode) => { } & GenericExplorerNode) => {
const t = useI18n(); const t = useI18n();
const { docsService, workbenchService } = useServices({ const { docsService, workbenchService, featureFlagService } = useServices({
DocsService, DocsService,
WorkbenchService, WorkbenchService,
CompatibleFavoriteItemsAdapter, CompatibleFavoriteItemsAdapter,
FeatureFlagService,
}); });
const openDocsSelector = useSelectDoc(); const openDocsSelector = useSelectDoc();
const openTagsSelector = useSelectTag(); const openTagsSelector = useSelectTag();
const openCollectionsSelector = useSelectCollection(); const openCollectionsSelector = useSelectCollection();
const name = useLiveData(node.name$); const name = useLiveData(node.name$);
const enableEmojiIcon = useLiveData(
featureFlagService.flags.enable_emoji_folder_icon.$
);
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const [newFolderId, setNewFolderId] = useState<string | null>(null); const [newFolderId, setNewFolderId] = useState<string | null>(null);
@@ -780,6 +791,7 @@ export const ExplorerFolderNodeFolder = ({
onDrop={handleDropOnFolder} onDrop={handleDropOnFolder}
defaultRenaming={defaultRenaming} defaultRenaming={defaultRenaming}
renameable renameable
extractEmojiAsIcon={enableEmojiIcon}
reorderable={reorderable} reorderable={reorderable}
collapsed={collapsed} collapsed={collapsed}
setCollapsed={handleCollapsedChange} setCollapsed={handleCollapsedChange}

View File

@@ -72,6 +72,15 @@ export const icon = style({
color: cssVarV2('icon/primary'), color: cssVarV2('icon/primary'),
fontSize: '20px', 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({ export const collapsedIconContainer = style({
width: '16px', width: '16px',
height: '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 { appSidebarWidthAtom } from '@affine/core/components/app-sidebar/index.jotai';
import { WorkbenchLink } from '@affine/core/modules/workbench'; import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd'; import type { AffineDNDData } from '@affine/core/types/dnd';
import { extractEmojiIcon } from '@affine/core/utils';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { import {
ArrowDownSmallIcon, ArrowDownSmallIcon,
@@ -59,7 +60,7 @@ export type ExplorerTreeNodeIcon = React.ComponentType<{
export const ExplorerTreeNode = ({ export const ExplorerTreeNode = ({
children, children,
icon: Icon, icon: Icon,
name, name: rawName,
onClick, onClick,
to, to,
active, active,
@@ -68,6 +69,7 @@ export const ExplorerTreeNode = ({
onRename, onRename,
disabled, disabled,
collapsed, collapsed,
extractEmojiAsIcon,
setCollapsed, setCollapsed,
canDrop, canDrop,
reorderable = true, reorderable = true,
@@ -87,6 +89,7 @@ export const ExplorerTreeNode = ({
active?: boolean; active?: boolean;
reorderable?: boolean; reorderable?: boolean;
defaultRenaming?: boolean; defaultRenaming?: boolean;
extractEmojiAsIcon?: boolean;
collapsed: boolean; collapsed: boolean;
setCollapsed: (collapsed: boolean) => void; setCollapsed: (collapsed: boolean) => void;
renameable?: boolean; renameable?: boolean;
@@ -117,6 +120,19 @@ export const ExplorerTreeNode = ({
const [renaming, setRenaming] = useState(defaultRenaming); const [renaming, setRenaming] = useState(defaultRenaming);
const [lastInGroup, setLastInGroup] = useState(false); const [lastInGroup, setLastInGroup] = useState(false);
const rootRef = useRef<HTMLDivElement>(null); 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< const { dragRef, dragging, CustomDragPreview } = useDraggable<
AffineDNDData & { draggable: { __cid: string } } AffineDNDData & { draggable: { __cid: string } }
>( >(
@@ -299,7 +315,6 @@ export const ExplorerTreeNode = ({
data-active={active} data-active={active}
data-disabled={disabled} data-disabled={disabled}
> >
{Icon && (
<div className={styles.iconsContainer}> <div className={styles.iconsContainer}>
<div <div
data-disabled={disabled} data-disabled={disabled}
@@ -312,21 +327,26 @@ export const ExplorerTreeNode = ({
data-collapsed={collapsed !== false} data-collapsed={collapsed !== false}
/> />
</div> </div>
{emoji ? (
<div className={styles.emojiIcon}>{emoji}</div>
) : (
Icon && (
<Icon <Icon
className={styles.icon} className={styles.icon}
draggedOver={draggedOver && !isSelfDraggedOver} draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction} treeInstruction={treeInstruction}
collapsed={collapsed} collapsed={collapsed}
/> />
</div> )
)} )}
</div>
{renameable && renaming && ( {renameable && renaming && (
<RenameModal <RenameModal
open open
width={sidebarWidth - 32} width={sidebarWidth - 32}
onOpenChange={setRenaming} onOpenChange={setRenaming}
onRename={handleRename} onRename={handleRename}
currentName={name ?? ''} currentName={rawName ?? ''}
> >
<div className={styles.itemRenameAnchor} /> <div className={styles.itemRenameAnchor} />
</RenameModal> </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 './create-emotion-cache';
export * from './event'; export * from './event';
export * from './extract-emoji-icon';
export * from './fractional-indexing'; export * from './fractional-indexing';
export * from './popup'; export * from './popup';
export * from './string2color'; export * from './string2color';

View File

@@ -444,6 +444,7 @@ __metadata:
fractional-indexing: "npm:^3.2.0" fractional-indexing: "npm:^3.2.0"
fractional-indexing-jittered: "npm:^0.9.1" fractional-indexing-jittered: "npm:^0.9.1"
fuse.js: "npm:^7.0.0" fuse.js: "npm:^7.0.0"
graphemer: "npm:^1.4.0"
graphql: "npm:^16.8.1" graphql: "npm:^16.8.1"
history: "npm:^5.3.0" history: "npm:^5.3.0"
idb: "npm:^8.0.0" idb: "npm:^8.0.0"