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 committed 2024-08-14 10:35:22 +00:00
1 parent 0504d0b0ff
commit 50948318e0
9 files changed
+108 -24

No files matched your search

@@ -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;
+1
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",
@@ -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}
@@ -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',
@@ -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,34 +315,38 @@ 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} onClick={handleCollapsedChange}
onClick={handleCollapsedChange} data-testid="explorer-collapsed-button"
data-testid="explorer-collapsed-button" className={styles.collapsedIconContainer}
className={styles.collapsedIconContainer} >
> <ArrowDownSmallIcon
<ArrowDownSmallIcon className={styles.collapsedIcon}
className={styles.collapsedIcon} data-collapsed={collapsed !== false}
data-collapsed={collapsed !== false}
/>
</div>
<Icon
className={styles.icon}
draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction}
collapsed={collapsed}
/> />
</div> </div>
)} {emoji ? (
<div className={styles.emojiIcon}>{emoji}</div>
) : (
Icon && (
<Icon
className={styles.icon}
draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction}
collapsed={collapsed}
/>
)
)}
</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>
@@ -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',
});
});
@@ -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,
};
}
@@ -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';
+1
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"