mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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']()}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
13
packages/frontend/core/src/modules/explorer-icon/index.ts
Normal file
13
packages/frontend/core/src/modules/explorer-icon/index.ts
Normal 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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.`
|
||||
*/
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user