mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(core): use emoji as folder icon (#7842)

This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
17
packages/frontend/core/src/utils/extract-emoji-icon.ts
Normal file
17
packages/frontend/core/src/utils/extract-emoji-icon.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user