feat(core): edit icon in navigation panel (#13595)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Rename dialog now edits per-item explorer icons (emoji or custom) and
can skip name-change callbacks. Doc icon picker added to the editor with
localized "Add icon" placeholder and readonly rendering. Icon editor
supports fallbacks, trigger variants, and improved input/test-id wiring.

- **Style**
- Updated icon picker and trigger sizing and placeholder visuals;
title/icon layout adjustments.

- **Chores**
- Explorer icon storage and module added to persist and serve icons
across the app.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-09-22 18:24:11 +08:00
committed by GitHub
parent 93554304e2
commit 195864fc88
25 changed files with 450 additions and 136 deletions

View File

@@ -19,6 +19,7 @@ const DOC_BLOCK_CHILD_PADDING = 24;
export class DocTitle extends WithDisposable(ShadowlessElement) {
static override styles = css`
.doc-icon-container,
.doc-title-container {
box-sizing: border-box;
font-family: var(--affine-font-family);
@@ -49,6 +50,7 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
/* Extra small devices (phones, 640px and down) */
@container viewport (width <= 640px) {
.doc-icon-container,
.doc-title-container {
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;

View File

@@ -14,13 +14,19 @@ export const contentRoot = style({
});
export const iconPicker = style({
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
width: 32,
height: 32,
padding: 0,
});
globalStyle(`${iconPicker} span:has(svg)`, {
lineHeight: 0,
});
export const iconNamePickerIcon = style({
flexShrink: 0,
fontSize: 24,
borderRadius: 4,
width: 32,
height: 32,
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
selectors: {
'&[data-icon-type="emoji"]': {
fontSize: 20,

View File

@@ -16,10 +16,10 @@ export default {
} satisfies Meta<typeof IconAndNameEditorMenu>;
export const Basic: StoryFn<IconAndNameEditorMenuProps> = () => {
const [icon, setIcon] = useState<string>('👋');
const [icon, setIcon] = useState<string | undefined>('👋');
const [name, setName] = useState<string>('Hello');
const handleIconChange = useCallback((_: IconType, icon: string) => {
const handleIconChange = useCallback((_?: IconType, icon?: string) => {
setIcon(icon);
}, []);
const handleNameChange = useCallback((name: string) => {

View File

@@ -4,7 +4,7 @@ import clsx from 'clsx';
import { useTheme } from 'next-themes';
import { type ReactNode, useCallback, useState } from 'react';
import { Button } from '../button';
import { Button, type ButtonProps } from '../button';
import Input from '../input';
import { Menu, type MenuProps } from '../menu';
import * as styles from './icon-name-editor.css';
@@ -12,11 +12,11 @@ import * as styles from './icon-name-editor.css';
export type IconType = 'emoji' | 'affine-icon' | 'blob';
export interface IconEditorProps {
iconType: IconType;
icon: string;
iconType?: IconType;
icon?: string;
closeAfterSelect?: boolean;
iconPlaceholder?: ReactNode;
onIconChange?: (type: IconType, icon: string) => void;
onIconChange?: (type?: IconType, icon?: string) => void;
triggerClassName?: string;
}
@@ -24,6 +24,8 @@ export interface IconAndNameEditorContentProps extends IconEditorProps {
name: string;
namePlaceholder?: string;
onNameChange?: (name: string) => void;
onEnter?: () => void;
inputTestId?: string;
}
export interface IconAndNameEditorMenuProps
@@ -33,20 +35,23 @@ export interface IconAndNameEditorMenuProps
onOpenChange?: (open: boolean) => void;
width?: string | number;
skipIfNotChanged?: boolean;
}
const IconRenderer = ({
export const IconRenderer = ({
iconType,
icon,
fallback,
}: {
iconType: IconType;
icon: string;
fallback?: ReactNode;
}) => {
switch (iconType) {
case 'emoji':
return <div>{icon}</div>;
return <div>{icon ?? fallback}</div>;
default:
throw new Error(`Unsupported icon type: ${iconType}`);
return <div>{fallback}</div>;
}
};
@@ -59,9 +64,11 @@ export const IconEditor = ({
onIconChange,
alignOffset,
sideOffset = 4,
triggerVariant,
}: IconEditorProps & {
alignOffset?: number;
sideOffset?: number;
triggerVariant?: ButtonProps['variant'];
}) => {
const [isPickerOpen, setIsPickerOpen] = useState(false);
const { resolvedTheme } = useTheme();
@@ -99,11 +106,18 @@ export const IconEditor = ({
}
>
<Button
variant={triggerVariant}
className={clsx(styles.iconPicker, triggerClassName)}
data-icon-type={iconType}
aria-label={icon ? 'Change Icon' : 'Select Icon'}
title={icon ? 'Change Icon' : 'Select Icon'}
>
{icon ? (
<IconRenderer iconType={iconType} icon={icon} />
{icon && iconType ? (
<IconRenderer
iconType={iconType}
icon={icon}
fallback={iconPlaceholder}
/>
) : (
iconPlaceholder
)}
@@ -115,17 +129,28 @@ export const IconEditor = ({
export const IconAndNameEditorContent = ({
name,
namePlaceholder,
inputTestId,
onNameChange,
onEnter,
...iconEditorProps
}: IconAndNameEditorContentProps) => {
return (
<div className={styles.contentRoot}>
<IconEditor {...iconEditorProps} alignOffset={-4} sideOffset={8} />
<IconEditor
{...iconEditorProps}
alignOffset={-4}
sideOffset={8}
triggerClassName={styles.iconNamePickerIcon}
/>
<Input
placeholder={namePlaceholder}
value={name}
onChange={onNameChange}
onEnter={onEnter}
className={styles.input}
autoSelect
autoFocus
data-testid={inputTestId}
/>
</div>
);
@@ -140,6 +165,10 @@ export const IconAndNameEditorMenu = ({
name: initialName,
onIconChange,
onNameChange,
contentOptions,
iconPlaceholder,
skipIfNotChanged = true,
inputTestId,
...menuProps
}: IconAndNameEditorMenuProps) => {
const [iconType, setIconType] = useState(initialIconType);
@@ -150,7 +179,9 @@ export const IconAndNameEditorMenu = ({
if (iconType !== initialIconType || icon !== initialIcon) {
onIconChange?.(iconType, icon);
}
if (name !== initialName) {
if (skipIfNotChanged) {
if (name !== initialName) onNameChange?.(name);
} else {
onNameChange?.(name);
}
}, [
@@ -162,13 +193,14 @@ export const IconAndNameEditorMenu = ({
name,
onIconChange,
onNameChange,
skipIfNotChanged,
]);
const abort = useCallback(() => {
setIconType(initialIconType);
setIcon(initialIcon);
setName(initialName);
}, [initialIcon, initialIconType, initialName]);
const handleIconChange = useCallback((type: IconType, icon: string) => {
const handleIconChange = useCallback((type?: IconType, icon?: string) => {
setIconType(type);
setIcon(icon);
}, []);
@@ -205,11 +237,8 @@ export const IconAndNameEditorMenu = ({
style: { width },
onPointerDownOutside: commit,
onEscapeKeyDown: abort,
...menuProps.contentOptions,
className: clsx(
styles.menuContent,
menuProps.contentOptions?.className
),
...contentOptions,
className: clsx(styles.menuContent, contentOptions?.className),
}}
{...menuProps}
items={
@@ -217,8 +246,14 @@ export const IconAndNameEditorMenu = ({
iconType={iconType}
icon={icon}
name={name}
iconPlaceholder={iconPlaceholder}
onIconChange={handleIconChange}
onNameChange={handleNameChange}
inputTestId={inputTestId}
onEnter={() => {
commit();
onOpenChange?.(false);
}}
/>
}
/>

View File

@@ -0,0 +1,31 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const docIconPickerTrigger = style({
width: 64,
height: 64,
padding: 2,
selectors: {
'&[data-icon-type="emoji"]': {
fontSize: 60,
lineHeight: 1,
},
},
});
export const placeholder = style({
padding: '4px',
});
export const placeholderContent = style({
display: 'flex',
alignItems: 'center',
gap: 4,
});
export const placeholderContentIcon = style({
color: cssVarV2.icon.secondary,
fontSize: 16,
});
export const placeholderContentText = style({
color: cssVarV2.text.secondary,
fontSize: 12,
});

View File

@@ -0,0 +1,82 @@
import { IconEditor, IconRenderer } from '@affine/component';
import { ExplorerIconService } from '@affine/core/modules/explorer-icon/services/explorer-icon';
import { useI18n } from '@affine/i18n';
import { SmileSolidIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import * as styles from './doc-icon-picker.css';
const TitleContainer = ({
children,
isPlaceholder,
}: {
children: React.ReactNode;
isPlaceholder: boolean;
}) => {
return (
<div
className="doc-icon-container"
style={{
paddingTop: 0,
paddingBottom: 0,
// title container has `padding-top`
transform: isPlaceholder ? 'translateY(80%)' : 'translateY(50%)',
}}
>
{children}
</div>
);
};
export const DocIconPicker = ({
docId,
readonly,
}: {
docId: string;
readonly?: boolean;
}) => {
const t = useI18n();
const explorerIconService = useService(ExplorerIconService);
const icon = useLiveData(explorerIconService.icon$('doc', docId));
const isPlaceholder = !icon?.type || !icon?.icon;
if (readonly) {
return isPlaceholder ? null : (
<div className={styles.docIconPickerTrigger} data-icon-type={icon?.type}>
<IconRenderer iconType={icon.type} icon={icon.icon} />
</div>
);
}
return (
<TitleContainer isPlaceholder={isPlaceholder}>
<IconEditor
iconType={icon?.type}
icon={icon?.icon}
onIconChange={(type, icon) => {
explorerIconService.setIcon({
where: 'doc',
id: docId,
type,
icon,
});
}}
closeAfterSelect={true}
triggerVariant="plain"
triggerClassName={
isPlaceholder ? styles.placeholder : styles.docIconPickerTrigger
}
iconPlaceholder={
<div className={styles.placeholderContent}>
<SmileSolidIcon className={styles.placeholderContentIcon} />
<span className={styles.placeholderContentText}>
{t['com.affine.docIconPicker.placeholder']()}
</span>
</div>
}
/>
</TitleContainer>
);
};

View File

@@ -48,6 +48,7 @@ import {
WorkspacePropertiesTable,
} from '../../components/properties';
import { BiDirectionalLinkPanel } from './bi-directional-link-panel';
import { DocIconPicker } from './doc-icon-picker';
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
import { StarterBar } from './starter-bar';
import * as styles from './styles.css';
@@ -254,6 +255,9 @@ export const BlocksuiteDocEditor = forwardRef<
return (
<>
<div className={styles.affineDocViewport}>
{!BUILD_CONFIG.isMobileEdition ? (
<DocIconPicker docId={page.id} readonly={readonly || shared} />
) : null}
{!isJournal ? (
<LitDocTitle doc={page} ref={onTitleRef} />
) : (

View File

@@ -229,6 +229,10 @@ export const NavigationPanelCollectionNode = ({
operations={finalOperations}
dropEffect={handleDropEffectOnCollection}
data-testid={`navigation-panel-collection-${collectionId}`}
explorerIconConfig={{
where: 'collection',
id: collectionId,
}}
>
<NavigationPanelCollectionNodeChildren
collection={collection}

View File

@@ -128,7 +128,7 @@ export const useNavigationPanelCollectionNodeOperations = (
),
},
{
index: 99,
index: 103,
view: (
<MenuItem prefixIcon={<FilterIcon />} onClick={handleShowEdit}>
{t['com.affine.collection.menu.edit']()}
@@ -136,7 +136,7 @@ export const useNavigationPanelCollectionNodeOperations = (
),
},
{
index: 99,
index: 102,
view: (
<MenuItem
prefixIcon={<PlusIcon />}
@@ -147,7 +147,7 @@ export const useNavigationPanelCollectionNodeOperations = (
),
},
{
index: 99,
index: 101,
view: (
<MenuItem
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
@@ -160,7 +160,7 @@ export const useNavigationPanelCollectionNodeOperations = (
),
},
{
index: 99,
index: 100,
view: (
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
{t['com.affine.workbench.tab.page-menu-open']()}

View File

@@ -314,6 +314,10 @@ export const NavigationPanelDocNode = ({
operations={finalOperations}
dropEffect={handleDropEffectOnDoc}
data-testid={`navigation-panel-doc-${docId}`}
explorerIconConfig={{
where: 'doc',
id: docId,
}}
>
{appSettings.showLinkedDocInSidebar ? (
<Guard docId={docId} permission="Doc_Read">

View File

@@ -811,6 +811,7 @@ const NavigationPanelFolderNodeFolder = ({
}
dropEffect={handleDropEffect}
data-testid={`navigation-panel-folder-${node.id}`}
explorerIconConfig={node.id ? { where: 'folder', id: node.id } : null}
>
{children.map(child => (
<NavigationPanelFolderNode

View File

@@ -200,6 +200,10 @@ export const NavigationPanelTagNode = ({
operations={finalOperations}
dropEffect={handleDropEffectOnTag}
data-testid={`navigation-panel-tag-${tagId}`}
explorerIconConfig={{
where: 'tag',
id: tagId,
}}
>
<NavigationPanelTagNodeDocs tag={tagRecord} path={path} />
</NavigationPanelTreeNode>

View File

@@ -1,4 +1,5 @@
import { IconButton } from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
@@ -10,7 +11,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { NavigationPanelTagNode } from '../../nodes/tag';
import { NavigationPanelTreeRoot } from '../../tree';
import { NavigationPanelTreeNodeRenameModal as CreateTagModal } from '../../tree/node';
import { RootEmpty } from './empty';
import * as styles from './styles.css';
@@ -62,11 +62,11 @@ export const NavigationPanelTags = () => {
<AddTagIcon />
</IconButton>
{creating && (
<CreateTagModal
setRenaming={setCreating}
handleRename={handleCreateNewTag}
rawName={t['com.affine.rootAppSidebar.tags.new-tag']()}
className={styles.createModalAnchor}
<RenameModal
open
onOpenChange={setCreating}
onRename={handleCreateNewTag}
currentName={t['com.affine.rootAppSidebar.tags.new-tag']()}
/>
)}
</div>

View File

@@ -4,19 +4,21 @@ import {
type DropTargetDropEvent,
type DropTargetOptions,
type DropTargetTreeInstruction,
IconAndNameEditorMenu,
IconButton,
Menu,
MenuItem,
useDraggable,
useDropTarget,
} from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal';
import { Guard } from '@affine/core/components/guard';
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import type { ExplorerIconType } from '@affine/core/modules/db/schema/schema';
import { ExplorerIconService } from '@affine/core/modules/explorer-icon/services/explorer-icon';
import type { ExplorerType } from '@affine/core/modules/explorer-icon/store/explorer-icon';
import type { DocPermissionActions } from '@affine/core/modules/permissions';
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,
@@ -83,6 +85,10 @@ export interface BaseNavigationPanelTreeNodeProps {
[key: `data-${string}`]: any;
}
type ExplorerIconConfig = {
where: ExplorerType;
id: string;
};
interface WebNavigationPanelTreeNodeProps
extends BaseNavigationPanelTreeNodeProps {
renameable?: boolean;
@@ -90,6 +96,8 @@ interface WebNavigationPanelTreeNodeProps
renameableGuard?: { docId: string; action: DocPermissionActions };
defaultRenaming?: boolean;
explorerIconConfig?: ExplorerIconConfig | null;
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
reorderable?: boolean;
dndData?: AffineDNDData;
@@ -105,25 +113,65 @@ export const NavigationPanelTreeNodeRenameModal = ({
setRenaming,
handleRename,
rawName,
explorerIconConfig,
className,
fallbackIcon,
}: {
setRenaming: (renaming: boolean) => void;
handleRename: (newName: string) => void;
rawName: string | undefined;
className?: string;
explorerIconConfig?: ExplorerIconConfig | null;
fallbackIcon?: React.ReactNode;
}) => {
const explorerIconService = useService(ExplorerIconService);
const appSidebarService = useService(AppSidebarService).sidebar;
const sidebarWidth = useLiveData(appSidebarService.width$);
const explorerIcon = useLiveData(
useMemo(
() =>
explorerIconConfig
? explorerIconService.icon$(
explorerIconConfig.where,
explorerIconConfig.id
)
: null,
[explorerIconConfig, explorerIconService]
)
);
const onIconChange = useCallback(
(type?: ExplorerIconType, icon?: string) => {
if (!explorerIconConfig) return;
explorerIconService.setIcon({
where: explorerIconConfig.where,
id: explorerIconConfig.id,
type,
icon,
});
},
[explorerIconConfig, explorerIconService]
);
return (
<RenameModal
<IconAndNameEditorMenu
open
width={sidebarWidth - 32}
onOpenChange={setRenaming}
onRename={handleRename}
currentName={rawName ?? ''}
onIconChange={onIconChange}
onNameChange={handleRename}
name={rawName ?? ''}
iconType={explorerIcon?.type ?? 'emoji'}
icon={explorerIcon?.icon ?? ''}
width={sidebarWidth - 16}
contentOptions={{
sideOffset: 36,
}}
iconPlaceholder={fallbackIcon}
inputTestId="rename-modal-input"
>
<div className={clsx(styles.itemRenameAnchor, className)} />
</RenameModal>
</IconAndNameEditorMenu>
);
};
@@ -140,7 +188,6 @@ export const NavigationPanelTreeNode = ({
onRename,
disabled,
collapsed,
extractEmojiAsIcon,
setCollapsed,
collapsible = true,
canDrop,
@@ -151,10 +198,12 @@ export const NavigationPanelTreeNode = ({
childrenPlaceholder,
linkComponent: LinkComponent = WorkbenchLink,
dndData,
explorerIconConfig,
onDrop,
dropEffect,
...otherProps
}: WebNavigationPanelTreeNodeProps) => {
const explorerIconService = useService(ExplorerIconService);
const t = useI18n();
const cid = useId();
const context = useContext(NavigationPanelTreeContext);
@@ -165,20 +214,19 @@ export const NavigationPanelTreeNode = ({
const [renaming, setRenaming] = useState(defaultRenaming);
const [lastInGroup, setLastInGroup] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const explorerIcon = useLiveData(
useMemo(
() =>
explorerIconConfig
? explorerIconService.icon$(
explorerIconConfig?.where,
explorerIconConfig?.id
)
: null,
[explorerIconConfig, explorerIconService]
)
);
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 } }
>(
@@ -384,6 +432,14 @@ export const NavigationPanelTreeNode = ({
[clickForCollapse, collapsed, collapsible, onClick, setCollapsed]
);
const fallbackIcon = Icon && (
<Icon
draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction}
collapsed={collapsed}
/>
);
const content = (
<div
onClick={handleClick}
@@ -405,19 +461,15 @@ export const NavigationPanelTreeNode = ({
/>
</div>
<div className={styles.iconContainer}>
{emoji ??
(Icon && (
<Icon
draggedOver={draggedOver && !isSelfDraggedOver}
treeInstruction={treeInstruction}
collapsed={collapsed}
/>
))}
{/* Only emoji icon is supported for now */}
{explorerIcon && explorerIcon.type === 'emoji'
? explorerIcon.icon
: fallbackIcon}
</div>
</div>
<div className={styles.itemMain}>
<div className={styles.itemContent}>{name}</div>
<div className={styles.itemContent}>{rawName}</div>
{postfix}
<div
className={styles.postfix}
@@ -452,6 +504,8 @@ export const NavigationPanelTreeNode = ({
setRenaming={setRenaming}
handleRename={handleRename}
rawName={rawName}
explorerIconConfig={explorerIconConfig}
fallbackIcon={fallbackIcon}
/>
)}
</div>

View File

@@ -46,6 +46,14 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
collectionId: f.string().primaryKey(),
index: f.string(),
},
explorerIcon: {
/**
* ${doc|collection|folder|tag}:${id}
*/
id: f.string().primaryKey(),
type: f.enum('emoji', 'affine-icon', 'blob'),
icon: f.string(),
},
} as const satisfies DBSchemaBuilder;
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
@@ -53,6 +61,10 @@ export type DocProperties = ORMEntity<AFFiNEWorkspaceDbSchema['docProperties']>;
export type DocCustomPropertyInfo = ORMEntity<
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
>;
export type ExplorerIcon = ORMEntity<AFFiNEWorkspaceDbSchema['explorerIcon']>;
export type ExplorerIconType = ORMEntity<
AFFiNEWorkspaceDbSchema['explorerIcon']
>['type'];
export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
favorite: {

View File

@@ -1,7 +1,7 @@
import { type Framework } from '@toeverything/infra';
import { DocsService } from '../doc';
import { FeatureFlagService } from '../feature-flag';
import { ExplorerIconService } from '../explorer-icon/services/explorer-icon';
import { I18nService } from '../i18n';
import { JournalService } from '../journal';
import { WorkspaceScope } from '../workspace';
@@ -15,7 +15,7 @@ export function configureDocDisplayMetaModule(framework: Framework) {
.service(DocDisplayMetaService, [
JournalService,
DocsService,
FeatureFlagService,
I18nService,
ExplorerIconService,
]);
}

View File

@@ -1,4 +1,3 @@
import { extractEmojiIcon } from '@affine/core/utils';
import { i18nTime } from '@affine/i18n';
import {
AliasIcon as LitAliasIcon,
@@ -27,7 +26,7 @@ import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import type { DocRecord, DocsService } from '../../doc';
import type { FeatureFlagService } from '../../feature-flag';
import type { ExplorerIconService } from '../../explorer-icon/services/explorer-icon';
import type { I18nService } from '../../i18n';
import type { JournalService } from '../../journal';
@@ -87,8 +86,8 @@ export class DocDisplayMetaService extends Service {
constructor(
private readonly journalService: JournalService,
private readonly docsService: DocsService,
private readonly featureFlagService: FeatureFlagService,
private readonly i18nService: I18nService
private readonly i18nService: I18nService,
private readonly explorerIconService: ExplorerIconService
) {
super();
}
@@ -128,30 +127,30 @@ export class DocDisplayMetaService extends Service {
const iconSet = icons[options?.type ?? 'rc'];
return LiveData.computed(get => {
const enableEmojiIcon =
get(this.featureFlagService.flags.enable_emoji_doc_icon.$) &&
options?.enableEmojiIcon !== false;
const enableEmojiIcon = options?.enableEmojiIcon !== false;
const doc = get(this.docsService.list.doc$(docId));
const referenced = !!options?.reference;
const titleAlias = referenced ? options?.title : undefined;
const originalTitle = doc ? get(doc.title$) : '';
// const originalTitle = doc ? get(doc.title$) : '';
// link to journal doc
const journalDateString = get(this.journalService.journalDate$(docId));
const journalIcon = journalDateString
? this.getJournalIcon(journalDateString, options)
: undefined;
const journalTitle = journalDateString
? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
: undefined;
const title = titleAlias ?? journalTitle ?? originalTitle;
// const journalTitle = journalDateString
// ? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
// : undefined;
// const title = titleAlias ?? journalTitle ?? originalTitle;
const mode = doc ? get(doc.primaryMode$) : undefined;
const finalMode = options?.mode ?? mode ?? 'page';
const referenceToNode = !!(referenced && options.referenceToNode);
// emoji title
if (enableEmojiIcon && title) {
const { emoji } = extractEmojiIcon(title);
if (emoji) return () => emoji;
if (enableEmojiIcon) {
// const { emoji } = extractEmojiIcon(title);
// if (emoji) return () => emoji;
const icon = get(this.explorerIconService.icon$('doc', docId));
if (icon && icon.type === 'emoji') return () => icon.icon;
}
// title alias
@@ -176,10 +175,6 @@ export class DocDisplayMetaService extends Service {
title$(docId: string, options?: DocDisplayTitleOptions) {
return LiveData.computed(get => {
const enableEmojiIcon =
get(this.featureFlagService.flags.enable_emoji_doc_icon.$) &&
options?.enableEmojiIcon !== false;
const lng = get(this.i18nService.i18n.currentLanguageKey$);
const doc = get(this.docsService.list.doc$(docId));
const referenced = !!options?.reference;
@@ -190,20 +185,17 @@ export class DocDisplayMetaService extends Service {
const journalTitle = journalDateString
? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
: undefined;
const title = titleAlias ?? journalTitle ?? originalTitle;
// const title = titleAlias ?? journalTitle ?? originalTitle;
// emoji title
if (enableEmojiIcon && title) {
const { rest } = extractEmojiIcon(title);
if (rest) return rest;
// When the title has only one emoji character,
// if it's a journal document, the date should be displayed.
const journalDateString = get(this.journalService.journalDate$(docId));
if (journalDateString) {
return i18nTime(journalDateString, { absolute: { accuracy: 'day' } });
}
}
// if (enableEmojiIcon && title) {
// // When the title has only one emoji character,
// // if it's a journal document, the date should be displayed.
// const journalDateString = get(this.journalService.journalDate$(docId));
// if (journalDateString) {
// return i18nTime(journalDateString, { absolute: { accuracy: 'day' } });
// }
// }
// title alias
if (titleAlias) return titleAlias;

View File

@@ -0,0 +1,13 @@
import type { Framework } from '@toeverything/infra';
import { WorkspaceDBService } from '../db';
import { WorkspaceScope } from '../workspace';
import { ExplorerIconService } from './services/explorer-icon';
import { ExplorerIconStore } from './store/explorer-icon';
export function configureExplorerIconModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.store(ExplorerIconStore, [WorkspaceDBService])
.service(ExplorerIconService, [ExplorerIconStore]);
}

View File

@@ -0,0 +1,21 @@
import { LiveData, Service } from '@toeverything/infra';
import type { ExplorerIconStore, ExplorerType } from '../store/explorer-icon';
export class ExplorerIconService extends Service {
constructor(private readonly store: ExplorerIconStore) {
super();
}
getIcon(type: ExplorerType, id: string) {
return this.store.getIcon(type, id);
}
setIcon(options: Parameters<ExplorerIconStore['setIcon']>[0]) {
return this.store.setIcon(options);
}
icon$(type: ExplorerType, id: string) {
return LiveData.from(this.store.watchIcon(type, id), null);
}
}

View File

@@ -0,0 +1,39 @@
import { Store } from '@toeverything/infra';
import type { WorkspaceDBService } from '../../db';
import type { ExplorerIconType } from '../../db/schema/schema';
export type ExplorerType = 'doc' | 'collection' | 'folder' | 'tag';
export class ExplorerIconStore extends Store {
constructor(private readonly dbService: WorkspaceDBService) {
super();
}
watchIcon(type: ExplorerType, id: string) {
return this.dbService.db.explorerIcon.get$(`${type}:${id}`);
}
getIcon(type: ExplorerType, id: string) {
return this.dbService.db.explorerIcon.get(`${type}:${id}`);
}
setIcon(options: {
where: ExplorerType;
id: string;
type?: ExplorerIconType;
icon?: string;
}) {
const { where, id, type, icon } = options;
// remove icon
if (!type || !icon) {
return this.dbService.db.explorerIcon.delete(`${where}:${id}`);
}
// upsert icon
return this.dbService.db.explorerIcon.create({
id: `${where}:${id}`,
type,
icon,
});
}
}

View File

@@ -27,6 +27,7 @@ import { configureDocSummaryModule } from './doc-summary';
import { configureDocsSearchModule } from './docs-search';
import { configureEditorModule } from './editor';
import { configureEditorSettingModule } from './editor-setting';
import { configureExplorerIconModule } from './explorer-icon';
import { configureFavoriteModule } from './favorite';
import { configureFeatureFlagModule } from './feature-flag';
import { configureGlobalContextModule } from './global-context';
@@ -85,6 +86,7 @@ export function configureCommonModules(framework: Framework) {
configureTelemetryModule(framework);
configurePDFModule(framework);
configurePeekViewModule(framework);
configureExplorerIconModule(framework);
configureDocDisplayMetaModule(framework);
configureQuickSearchModule(framework);
configureDocsSearchModule(framework);

View File

@@ -2,6 +2,9 @@ import Graphemer from 'graphemer';
const emojiRe =
/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/g;
/**
* @deprecated use ExplorerIconService instead
*/
export function extractEmojiIcon(text: string) {
emojiRe.lastIndex = 0;
const match = emojiRe.exec(text);

View File

@@ -8314,6 +8314,10 @@ export function useAFFiNEI18N(): {
* `Cut`
*/
["com.affine.context-menu.cut"](): string;
/**
* `Add icon`
*/
["com.affine.docIconPicker.placeholder"](): string;
/**
* `An internal error occurred.`
*/

View File

@@ -2086,6 +2086,7 @@
"com.affine.context-menu.copy": "Copy",
"com.affine.context-menu.paste": "Paste",
"com.affine.context-menu.cut": "Cut",
"com.affine.docIconPicker.placeholder": "Add icon",
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
"error.NETWORK_ERROR": "Network error.",
"error.TOO_MANY_REQUEST": "Too many requests.",

View File

@@ -821,64 +821,64 @@ test.describe('Customize linked doc title and description', () => {
await expect(cardDescription).toBeHidden();
});
test('should show emoji doc icon in normal document', async ({ page }) => {
await enableEmojiDocIcon(page);
// test('should show emoji doc icon in normal document', async ({ page }) => {
// await enableEmojiDocIcon(page);
await clickNewPageButton(page);
const title = getBlockSuiteEditorTitle(page);
await title.click();
// await clickNewPageButton(page);
// const title = getBlockSuiteEditorTitle(page);
// await title.click();
await page.keyboard.press('Enter');
await createLinkedPage(page, 'Test Page');
// await page.keyboard.press('Enter');
// await createLinkedPage(page, 'Test Page');
const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
// const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
const inlineLink = page.locator('affine-reference');
await inlineLink.hover();
// const inlineLink = page.locator('affine-reference');
// await inlineLink.hover();
// Edits title
await toolbar.getByRole('button', { name: 'Edit' }).click();
// // Edits title
// await toolbar.getByRole('button', { name: 'Edit' }).click();
// Title alias
await page.keyboard.type('🦀hello');
await page.keyboard.press('Enter');
// // Title alias
// await page.keyboard.type('🦀hello');
// await page.keyboard.press('Enter');
const a = inlineLink.locator('a');
// const a = inlineLink.locator('a');
await expect(a).toHaveText('🦀hello');
await expect(a.locator('svg')).toBeHidden();
await expect(a.locator('.affine-reference-title')).toHaveText('hello');
});
// await expect(a).toHaveText('🦀hello');
// await expect(a.locator('svg')).toBeHidden();
// await expect(a.locator('.affine-reference-title')).toHaveText('hello');
// });
test('should show emoji doc icon in journal document', async ({ page }) => {
await enableEmojiDocIcon(page);
// test('should show emoji doc icon in journal document', async ({ page }) => {
// await enableEmojiDocIcon(page);
await clickNewPageButton(page);
const title = getBlockSuiteEditorTitle(page);
await title.click();
// await clickNewPageButton(page);
// const title = getBlockSuiteEditorTitle(page);
// await title.click();
await page.keyboard.press('Enter');
await createTodayPage(page);
// await page.keyboard.press('Enter');
// await createTodayPage(page);
const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
// const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
const inlineLink = page.locator('affine-reference');
await inlineLink.hover();
// const inlineLink = page.locator('affine-reference');
// await inlineLink.hover();
// Edits title
await toolbar.getByRole('button', { name: 'Edit' }).click();
// // Edits title
// await toolbar.getByRole('button', { name: 'Edit' }).click();
// Title alias
await page.keyboard.type('🦀');
await page.keyboard.press('Enter');
// // Title alias
// await page.keyboard.type('🦀');
// await page.keyboard.press('Enter');
const a = inlineLink.locator('a');
// const a = inlineLink.locator('a');
const year = String(new Date().getFullYear());
await expect(a).toContainText('🦀');
await expect(a.locator('svg')).toBeHidden();
await expect(a.locator('.affine-reference-title')).toContainText(year);
});
// const year = String(new Date().getFullYear());
// await expect(a).toContainText('🦀');
// await expect(a.locator('svg')).toBeHidden();
// await expect(a.locator('.affine-reference-title')).toContainText(year);
// });
});
test('should save open doc mode of internal links', async ({ page }) => {