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) {
|
export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
|
.doc-icon-container,
|
||||||
.doc-title-container {
|
.doc-title-container {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: var(--affine-font-family);
|
font-family: var(--affine-font-family);
|
||||||
@@ -49,6 +50,7 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
|
|||||||
|
|
||||||
/* Extra small devices (phones, 640px and down) */
|
/* Extra small devices (phones, 640px and down) */
|
||||||
@container viewport (width <= 640px) {
|
@container viewport (width <= 640px) {
|
||||||
|
.doc-icon-container,
|
||||||
.doc-title-container {
|
.doc-title-container {
|
||||||
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
|
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||||
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
|
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||||
|
|||||||
@@ -14,13 +14,19 @@ export const contentRoot = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const iconPicker = style({
|
export const iconPicker = style({
|
||||||
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
});
|
||||||
|
globalStyle(`${iconPicker} span:has(svg)`, {
|
||||||
|
lineHeight: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconNamePickerIcon = style({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-icon-type="emoji"]': {
|
'&[data-icon-type="emoji"]': {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ export default {
|
|||||||
} satisfies Meta<typeof IconAndNameEditorMenu>;
|
} satisfies Meta<typeof IconAndNameEditorMenu>;
|
||||||
|
|
||||||
export const Basic: StoryFn<IconAndNameEditorMenuProps> = () => {
|
export const Basic: StoryFn<IconAndNameEditorMenuProps> = () => {
|
||||||
const [icon, setIcon] = useState<string>('👋');
|
const [icon, setIcon] = useState<string | undefined>('👋');
|
||||||
const [name, setName] = useState<string>('Hello');
|
const [name, setName] = useState<string>('Hello');
|
||||||
|
|
||||||
const handleIconChange = useCallback((_: IconType, icon: string) => {
|
const handleIconChange = useCallback((_?: IconType, icon?: string) => {
|
||||||
setIcon(icon);
|
setIcon(icon);
|
||||||
}, []);
|
}, []);
|
||||||
const handleNameChange = useCallback((name: string) => {
|
const handleNameChange = useCallback((name: string) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import clsx from 'clsx';
|
|||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { type ReactNode, useCallback, useState } from 'react';
|
import { type ReactNode, useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '../button';
|
import { Button, type ButtonProps } from '../button';
|
||||||
import Input from '../input';
|
import Input from '../input';
|
||||||
import { Menu, type MenuProps } from '../menu';
|
import { Menu, type MenuProps } from '../menu';
|
||||||
import * as styles from './icon-name-editor.css';
|
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 type IconType = 'emoji' | 'affine-icon' | 'blob';
|
||||||
|
|
||||||
export interface IconEditorProps {
|
export interface IconEditorProps {
|
||||||
iconType: IconType;
|
iconType?: IconType;
|
||||||
icon: string;
|
icon?: string;
|
||||||
closeAfterSelect?: boolean;
|
closeAfterSelect?: boolean;
|
||||||
iconPlaceholder?: ReactNode;
|
iconPlaceholder?: ReactNode;
|
||||||
onIconChange?: (type: IconType, icon: string) => void;
|
onIconChange?: (type?: IconType, icon?: string) => void;
|
||||||
triggerClassName?: string;
|
triggerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ export interface IconAndNameEditorContentProps extends IconEditorProps {
|
|||||||
name: string;
|
name: string;
|
||||||
namePlaceholder?: string;
|
namePlaceholder?: string;
|
||||||
onNameChange?: (name: string) => void;
|
onNameChange?: (name: string) => void;
|
||||||
|
onEnter?: () => void;
|
||||||
|
inputTestId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IconAndNameEditorMenuProps
|
export interface IconAndNameEditorMenuProps
|
||||||
@@ -33,20 +35,23 @@ export interface IconAndNameEditorMenuProps
|
|||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
|
||||||
width?: string | number;
|
width?: string | number;
|
||||||
|
skipIfNotChanged?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconRenderer = ({
|
export const IconRenderer = ({
|
||||||
iconType,
|
iconType,
|
||||||
icon,
|
icon,
|
||||||
|
fallback,
|
||||||
}: {
|
}: {
|
||||||
iconType: IconType;
|
iconType: IconType;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
fallback?: ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
switch (iconType) {
|
switch (iconType) {
|
||||||
case 'emoji':
|
case 'emoji':
|
||||||
return <div>{icon}</div>;
|
return <div>{icon ?? fallback}</div>;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported icon type: ${iconType}`);
|
return <div>{fallback}</div>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,9 +64,11 @@ export const IconEditor = ({
|
|||||||
onIconChange,
|
onIconChange,
|
||||||
alignOffset,
|
alignOffset,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
|
triggerVariant,
|
||||||
}: IconEditorProps & {
|
}: IconEditorProps & {
|
||||||
alignOffset?: number;
|
alignOffset?: number;
|
||||||
sideOffset?: number;
|
sideOffset?: number;
|
||||||
|
triggerVariant?: ButtonProps['variant'];
|
||||||
}) => {
|
}) => {
|
||||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
@@ -99,11 +106,18 @@ export const IconEditor = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
variant={triggerVariant}
|
||||||
className={clsx(styles.iconPicker, triggerClassName)}
|
className={clsx(styles.iconPicker, triggerClassName)}
|
||||||
data-icon-type={iconType}
|
data-icon-type={iconType}
|
||||||
|
aria-label={icon ? 'Change Icon' : 'Select Icon'}
|
||||||
|
title={icon ? 'Change Icon' : 'Select Icon'}
|
||||||
>
|
>
|
||||||
{icon ? (
|
{icon && iconType ? (
|
||||||
<IconRenderer iconType={iconType} icon={icon} />
|
<IconRenderer
|
||||||
|
iconType={iconType}
|
||||||
|
icon={icon}
|
||||||
|
fallback={iconPlaceholder}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
iconPlaceholder
|
iconPlaceholder
|
||||||
)}
|
)}
|
||||||
@@ -115,17 +129,28 @@ export const IconEditor = ({
|
|||||||
export const IconAndNameEditorContent = ({
|
export const IconAndNameEditorContent = ({
|
||||||
name,
|
name,
|
||||||
namePlaceholder,
|
namePlaceholder,
|
||||||
|
inputTestId,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
|
onEnter,
|
||||||
...iconEditorProps
|
...iconEditorProps
|
||||||
}: IconAndNameEditorContentProps) => {
|
}: IconAndNameEditorContentProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.contentRoot}>
|
<div className={styles.contentRoot}>
|
||||||
<IconEditor {...iconEditorProps} alignOffset={-4} sideOffset={8} />
|
<IconEditor
|
||||||
|
{...iconEditorProps}
|
||||||
|
alignOffset={-4}
|
||||||
|
sideOffset={8}
|
||||||
|
triggerClassName={styles.iconNamePickerIcon}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder={namePlaceholder}
|
placeholder={namePlaceholder}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={onNameChange}
|
onChange={onNameChange}
|
||||||
|
onEnter={onEnter}
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
|
autoSelect
|
||||||
|
autoFocus
|
||||||
|
data-testid={inputTestId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -140,6 +165,10 @@ export const IconAndNameEditorMenu = ({
|
|||||||
name: initialName,
|
name: initialName,
|
||||||
onIconChange,
|
onIconChange,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
|
contentOptions,
|
||||||
|
iconPlaceholder,
|
||||||
|
skipIfNotChanged = true,
|
||||||
|
inputTestId,
|
||||||
...menuProps
|
...menuProps
|
||||||
}: IconAndNameEditorMenuProps) => {
|
}: IconAndNameEditorMenuProps) => {
|
||||||
const [iconType, setIconType] = useState(initialIconType);
|
const [iconType, setIconType] = useState(initialIconType);
|
||||||
@@ -150,7 +179,9 @@ export const IconAndNameEditorMenu = ({
|
|||||||
if (iconType !== initialIconType || icon !== initialIcon) {
|
if (iconType !== initialIconType || icon !== initialIcon) {
|
||||||
onIconChange?.(iconType, icon);
|
onIconChange?.(iconType, icon);
|
||||||
}
|
}
|
||||||
if (name !== initialName) {
|
if (skipIfNotChanged) {
|
||||||
|
if (name !== initialName) onNameChange?.(name);
|
||||||
|
} else {
|
||||||
onNameChange?.(name);
|
onNameChange?.(name);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -162,13 +193,14 @@ export const IconAndNameEditorMenu = ({
|
|||||||
name,
|
name,
|
||||||
onIconChange,
|
onIconChange,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
|
skipIfNotChanged,
|
||||||
]);
|
]);
|
||||||
const abort = useCallback(() => {
|
const abort = useCallback(() => {
|
||||||
setIconType(initialIconType);
|
setIconType(initialIconType);
|
||||||
setIcon(initialIcon);
|
setIcon(initialIcon);
|
||||||
setName(initialName);
|
setName(initialName);
|
||||||
}, [initialIcon, initialIconType, initialName]);
|
}, [initialIcon, initialIconType, initialName]);
|
||||||
const handleIconChange = useCallback((type: IconType, icon: string) => {
|
const handleIconChange = useCallback((type?: IconType, icon?: string) => {
|
||||||
setIconType(type);
|
setIconType(type);
|
||||||
setIcon(icon);
|
setIcon(icon);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -205,11 +237,8 @@ export const IconAndNameEditorMenu = ({
|
|||||||
style: { width },
|
style: { width },
|
||||||
onPointerDownOutside: commit,
|
onPointerDownOutside: commit,
|
||||||
onEscapeKeyDown: abort,
|
onEscapeKeyDown: abort,
|
||||||
...menuProps.contentOptions,
|
...contentOptions,
|
||||||
className: clsx(
|
className: clsx(styles.menuContent, contentOptions?.className),
|
||||||
styles.menuContent,
|
|
||||||
menuProps.contentOptions?.className
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
{...menuProps}
|
{...menuProps}
|
||||||
items={
|
items={
|
||||||
@@ -217,8 +246,14 @@ export const IconAndNameEditorMenu = ({
|
|||||||
iconType={iconType}
|
iconType={iconType}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
name={name}
|
name={name}
|
||||||
|
iconPlaceholder={iconPlaceholder}
|
||||||
onIconChange={handleIconChange}
|
onIconChange={handleIconChange}
|
||||||
onNameChange={handleNameChange}
|
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,
|
WorkspacePropertiesTable,
|
||||||
} from '../../components/properties';
|
} from '../../components/properties';
|
||||||
import { BiDirectionalLinkPanel } from './bi-directional-link-panel';
|
import { BiDirectionalLinkPanel } from './bi-directional-link-panel';
|
||||||
|
import { DocIconPicker } from './doc-icon-picker';
|
||||||
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
|
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
|
||||||
import { StarterBar } from './starter-bar';
|
import { StarterBar } from './starter-bar';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
@@ -254,6 +255,9 @@ export const BlocksuiteDocEditor = forwardRef<
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.affineDocViewport}>
|
<div className={styles.affineDocViewport}>
|
||||||
|
{!BUILD_CONFIG.isMobileEdition ? (
|
||||||
|
<DocIconPicker docId={page.id} readonly={readonly || shared} />
|
||||||
|
) : null}
|
||||||
{!isJournal ? (
|
{!isJournal ? (
|
||||||
<LitDocTitle doc={page} ref={onTitleRef} />
|
<LitDocTitle doc={page} ref={onTitleRef} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ export const NavigationPanelCollectionNode = ({
|
|||||||
operations={finalOperations}
|
operations={finalOperations}
|
||||||
dropEffect={handleDropEffectOnCollection}
|
dropEffect={handleDropEffectOnCollection}
|
||||||
data-testid={`navigation-panel-collection-${collectionId}`}
|
data-testid={`navigation-panel-collection-${collectionId}`}
|
||||||
|
explorerIconConfig={{
|
||||||
|
where: 'collection',
|
||||||
|
id: collectionId,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<NavigationPanelCollectionNodeChildren
|
<NavigationPanelCollectionNodeChildren
|
||||||
collection={collection}
|
collection={collection}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const useNavigationPanelCollectionNodeOperations = (
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 99,
|
index: 103,
|
||||||
view: (
|
view: (
|
||||||
<MenuItem prefixIcon={<FilterIcon />} onClick={handleShowEdit}>
|
<MenuItem prefixIcon={<FilterIcon />} onClick={handleShowEdit}>
|
||||||
{t['com.affine.collection.menu.edit']()}
|
{t['com.affine.collection.menu.edit']()}
|
||||||
@@ -136,7 +136,7 @@ export const useNavigationPanelCollectionNodeOperations = (
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 99,
|
index: 102,
|
||||||
view: (
|
view: (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
prefixIcon={<PlusIcon />}
|
prefixIcon={<PlusIcon />}
|
||||||
@@ -147,7 +147,7 @@ export const useNavigationPanelCollectionNodeOperations = (
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 99,
|
index: 101,
|
||||||
view: (
|
view: (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
|
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
|
||||||
@@ -160,7 +160,7 @@ export const useNavigationPanelCollectionNodeOperations = (
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 99,
|
index: 100,
|
||||||
view: (
|
view: (
|
||||||
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
||||||
{t['com.affine.workbench.tab.page-menu-open']()}
|
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||||
|
|||||||
@@ -314,6 +314,10 @@ export const NavigationPanelDocNode = ({
|
|||||||
operations={finalOperations}
|
operations={finalOperations}
|
||||||
dropEffect={handleDropEffectOnDoc}
|
dropEffect={handleDropEffectOnDoc}
|
||||||
data-testid={`navigation-panel-doc-${docId}`}
|
data-testid={`navigation-panel-doc-${docId}`}
|
||||||
|
explorerIconConfig={{
|
||||||
|
where: 'doc',
|
||||||
|
id: docId,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{appSettings.showLinkedDocInSidebar ? (
|
{appSettings.showLinkedDocInSidebar ? (
|
||||||
<Guard docId={docId} permission="Doc_Read">
|
<Guard docId={docId} permission="Doc_Read">
|
||||||
|
|||||||
@@ -811,6 +811,7 @@ const NavigationPanelFolderNodeFolder = ({
|
|||||||
}
|
}
|
||||||
dropEffect={handleDropEffect}
|
dropEffect={handleDropEffect}
|
||||||
data-testid={`navigation-panel-folder-${node.id}`}
|
data-testid={`navigation-panel-folder-${node.id}`}
|
||||||
|
explorerIconConfig={node.id ? { where: 'folder', id: node.id } : null}
|
||||||
>
|
>
|
||||||
{children.map(child => (
|
{children.map(child => (
|
||||||
<NavigationPanelFolderNode
|
<NavigationPanelFolderNode
|
||||||
|
|||||||
@@ -200,6 +200,10 @@ export const NavigationPanelTagNode = ({
|
|||||||
operations={finalOperations}
|
operations={finalOperations}
|
||||||
dropEffect={handleDropEffectOnTag}
|
dropEffect={handleDropEffectOnTag}
|
||||||
data-testid={`navigation-panel-tag-${tagId}`}
|
data-testid={`navigation-panel-tag-${tagId}`}
|
||||||
|
explorerIconConfig={{
|
||||||
|
where: 'tag',
|
||||||
|
id: tagId,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<NavigationPanelTagNodeDocs tag={tagRecord} path={path} />
|
<NavigationPanelTagNodeDocs tag={tagRecord} path={path} />
|
||||||
</NavigationPanelTreeNode>
|
</NavigationPanelTreeNode>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IconButton } from '@affine/component';
|
import { IconButton } from '@affine/component';
|
||||||
|
import { RenameModal } from '@affine/component/rename-modal';
|
||||||
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
|
||||||
import { TagService } from '@affine/core/modules/tag';
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
@@ -10,7 +11,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||||
import { NavigationPanelTagNode } from '../../nodes/tag';
|
import { NavigationPanelTagNode } from '../../nodes/tag';
|
||||||
import { NavigationPanelTreeRoot } from '../../tree';
|
import { NavigationPanelTreeRoot } from '../../tree';
|
||||||
import { NavigationPanelTreeNodeRenameModal as CreateTagModal } from '../../tree/node';
|
|
||||||
import { RootEmpty } from './empty';
|
import { RootEmpty } from './empty';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
@@ -62,11 +62,11 @@ export const NavigationPanelTags = () => {
|
|||||||
<AddTagIcon />
|
<AddTagIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{creating && (
|
{creating && (
|
||||||
<CreateTagModal
|
<RenameModal
|
||||||
setRenaming={setCreating}
|
open
|
||||||
handleRename={handleCreateNewTag}
|
onOpenChange={setCreating}
|
||||||
rawName={t['com.affine.rootAppSidebar.tags.new-tag']()}
|
onRename={handleCreateNewTag}
|
||||||
className={styles.createModalAnchor}
|
currentName={t['com.affine.rootAppSidebar.tags.new-tag']()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ import {
|
|||||||
type DropTargetDropEvent,
|
type DropTargetDropEvent,
|
||||||
type DropTargetOptions,
|
type DropTargetOptions,
|
||||||
type DropTargetTreeInstruction,
|
type DropTargetTreeInstruction,
|
||||||
|
IconAndNameEditorMenu,
|
||||||
IconButton,
|
IconButton,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
useDraggable,
|
useDraggable,
|
||||||
useDropTarget,
|
useDropTarget,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { RenameModal } from '@affine/component/rename-modal';
|
|
||||||
import { Guard } from '@affine/core/components/guard';
|
import { Guard } from '@affine/core/components/guard';
|
||||||
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
|
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 type { DocPermissionActions } from '@affine/core/modules/permissions';
|
||||||
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,
|
||||||
@@ -83,6 +85,10 @@ export interface BaseNavigationPanelTreeNodeProps {
|
|||||||
[key: `data-${string}`]: any;
|
[key: `data-${string}`]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExplorerIconConfig = {
|
||||||
|
where: ExplorerType;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
interface WebNavigationPanelTreeNodeProps
|
interface WebNavigationPanelTreeNodeProps
|
||||||
extends BaseNavigationPanelTreeNodeProps {
|
extends BaseNavigationPanelTreeNodeProps {
|
||||||
renameable?: boolean;
|
renameable?: boolean;
|
||||||
@@ -90,6 +96,8 @@ interface WebNavigationPanelTreeNodeProps
|
|||||||
renameableGuard?: { docId: string; action: DocPermissionActions };
|
renameableGuard?: { docId: string; action: DocPermissionActions };
|
||||||
defaultRenaming?: boolean;
|
defaultRenaming?: boolean;
|
||||||
|
|
||||||
|
explorerIconConfig?: ExplorerIconConfig | null;
|
||||||
|
|
||||||
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||||
reorderable?: boolean;
|
reorderable?: boolean;
|
||||||
dndData?: AffineDNDData;
|
dndData?: AffineDNDData;
|
||||||
@@ -105,25 +113,65 @@ export const NavigationPanelTreeNodeRenameModal = ({
|
|||||||
setRenaming,
|
setRenaming,
|
||||||
handleRename,
|
handleRename,
|
||||||
rawName,
|
rawName,
|
||||||
|
explorerIconConfig,
|
||||||
className,
|
className,
|
||||||
|
fallbackIcon,
|
||||||
}: {
|
}: {
|
||||||
setRenaming: (renaming: boolean) => void;
|
setRenaming: (renaming: boolean) => void;
|
||||||
handleRename: (newName: string) => void;
|
handleRename: (newName: string) => void;
|
||||||
rawName: string | undefined;
|
rawName: string | undefined;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
explorerIconConfig?: ExplorerIconConfig | null;
|
||||||
|
fallbackIcon?: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
|
const explorerIconService = useService(ExplorerIconService);
|
||||||
const appSidebarService = useService(AppSidebarService).sidebar;
|
const appSidebarService = useService(AppSidebarService).sidebar;
|
||||||
const sidebarWidth = useLiveData(appSidebarService.width$);
|
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 (
|
return (
|
||||||
<RenameModal
|
<IconAndNameEditorMenu
|
||||||
open
|
open
|
||||||
width={sidebarWidth - 32}
|
|
||||||
onOpenChange={setRenaming}
|
onOpenChange={setRenaming}
|
||||||
onRename={handleRename}
|
onIconChange={onIconChange}
|
||||||
currentName={rawName ?? ''}
|
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)} />
|
<div className={clsx(styles.itemRenameAnchor, className)} />
|
||||||
</RenameModal>
|
</IconAndNameEditorMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,7 +188,6 @@ export const NavigationPanelTreeNode = ({
|
|||||||
onRename,
|
onRename,
|
||||||
disabled,
|
disabled,
|
||||||
collapsed,
|
collapsed,
|
||||||
extractEmojiAsIcon,
|
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
collapsible = true,
|
collapsible = true,
|
||||||
canDrop,
|
canDrop,
|
||||||
@@ -151,10 +198,12 @@ export const NavigationPanelTreeNode = ({
|
|||||||
childrenPlaceholder,
|
childrenPlaceholder,
|
||||||
linkComponent: LinkComponent = WorkbenchLink,
|
linkComponent: LinkComponent = WorkbenchLink,
|
||||||
dndData,
|
dndData,
|
||||||
|
explorerIconConfig,
|
||||||
onDrop,
|
onDrop,
|
||||||
dropEffect,
|
dropEffect,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: WebNavigationPanelTreeNodeProps) => {
|
}: WebNavigationPanelTreeNodeProps) => {
|
||||||
|
const explorerIconService = useService(ExplorerIconService);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const cid = useId();
|
const cid = useId();
|
||||||
const context = useContext(NavigationPanelTreeContext);
|
const context = useContext(NavigationPanelTreeContext);
|
||||||
@@ -165,20 +214,19 @@ export const NavigationPanelTreeNode = ({
|
|||||||
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 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<
|
const { dragRef, dragging, CustomDragPreview } = useDraggable<
|
||||||
AffineDNDData & { draggable: { __cid: string } }
|
AffineDNDData & { draggable: { __cid: string } }
|
||||||
>(
|
>(
|
||||||
@@ -384,6 +432,14 @@ export const NavigationPanelTreeNode = ({
|
|||||||
[clickForCollapse, collapsed, collapsible, onClick, setCollapsed]
|
[clickForCollapse, collapsed, collapsible, onClick, setCollapsed]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fallbackIcon = Icon && (
|
||||||
|
<Icon
|
||||||
|
draggedOver={draggedOver && !isSelfDraggedOver}
|
||||||
|
treeInstruction={treeInstruction}
|
||||||
|
collapsed={collapsed}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -405,19 +461,15 @@ export const NavigationPanelTreeNode = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.iconContainer}>
|
<div className={styles.iconContainer}>
|
||||||
{emoji ??
|
{/* Only emoji icon is supported for now */}
|
||||||
(Icon && (
|
{explorerIcon && explorerIcon.type === 'emoji'
|
||||||
<Icon
|
? explorerIcon.icon
|
||||||
draggedOver={draggedOver && !isSelfDraggedOver}
|
: fallbackIcon}
|
||||||
treeInstruction={treeInstruction}
|
|
||||||
collapsed={collapsed}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.itemMain}>
|
<div className={styles.itemMain}>
|
||||||
<div className={styles.itemContent}>{name}</div>
|
<div className={styles.itemContent}>{rawName}</div>
|
||||||
{postfix}
|
{postfix}
|
||||||
<div
|
<div
|
||||||
className={styles.postfix}
|
className={styles.postfix}
|
||||||
@@ -452,6 +504,8 @@ export const NavigationPanelTreeNode = ({
|
|||||||
setRenaming={setRenaming}
|
setRenaming={setRenaming}
|
||||||
handleRename={handleRename}
|
handleRename={handleRename}
|
||||||
rawName={rawName}
|
rawName={rawName}
|
||||||
|
explorerIconConfig={explorerIconConfig}
|
||||||
|
fallbackIcon={fallbackIcon}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = {
|
|||||||
collectionId: f.string().primaryKey(),
|
collectionId: f.string().primaryKey(),
|
||||||
index: f.string(),
|
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;
|
} as const satisfies DBSchemaBuilder;
|
||||||
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
|
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
|
||||||
|
|
||||||
@@ -53,6 +61,10 @@ export type DocProperties = ORMEntity<AFFiNEWorkspaceDbSchema['docProperties']>;
|
|||||||
export type DocCustomPropertyInfo = ORMEntity<
|
export type DocCustomPropertyInfo = ORMEntity<
|
||||||
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
|
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
|
||||||
>;
|
>;
|
||||||
|
export type ExplorerIcon = ORMEntity<AFFiNEWorkspaceDbSchema['explorerIcon']>;
|
||||||
|
export type ExplorerIconType = ORMEntity<
|
||||||
|
AFFiNEWorkspaceDbSchema['explorerIcon']
|
||||||
|
>['type'];
|
||||||
|
|
||||||
export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
|
export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
|
||||||
favorite: {
|
favorite: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type Framework } from '@toeverything/infra';
|
import { type Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
import { DocsService } from '../doc';
|
import { DocsService } from '../doc';
|
||||||
import { FeatureFlagService } from '../feature-flag';
|
import { ExplorerIconService } from '../explorer-icon/services/explorer-icon';
|
||||||
import { I18nService } from '../i18n';
|
import { I18nService } from '../i18n';
|
||||||
import { JournalService } from '../journal';
|
import { JournalService } from '../journal';
|
||||||
import { WorkspaceScope } from '../workspace';
|
import { WorkspaceScope } from '../workspace';
|
||||||
@@ -15,7 +15,7 @@ export function configureDocDisplayMetaModule(framework: Framework) {
|
|||||||
.service(DocDisplayMetaService, [
|
.service(DocDisplayMetaService, [
|
||||||
JournalService,
|
JournalService,
|
||||||
DocsService,
|
DocsService,
|
||||||
FeatureFlagService,
|
|
||||||
I18nService,
|
I18nService,
|
||||||
|
ExplorerIconService,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { extractEmojiIcon } from '@affine/core/utils';
|
|
||||||
import { i18nTime } from '@affine/i18n';
|
import { i18nTime } from '@affine/i18n';
|
||||||
import {
|
import {
|
||||||
AliasIcon as LitAliasIcon,
|
AliasIcon as LitAliasIcon,
|
||||||
@@ -27,7 +26,7 @@ import type { Dayjs } from 'dayjs';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import type { DocRecord, DocsService } from '../../doc';
|
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 { I18nService } from '../../i18n';
|
||||||
import type { JournalService } from '../../journal';
|
import type { JournalService } from '../../journal';
|
||||||
|
|
||||||
@@ -87,8 +86,8 @@ export class DocDisplayMetaService extends Service {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly journalService: JournalService,
|
private readonly journalService: JournalService,
|
||||||
private readonly docsService: DocsService,
|
private readonly docsService: DocsService,
|
||||||
private readonly featureFlagService: FeatureFlagService,
|
private readonly i18nService: I18nService,
|
||||||
private readonly i18nService: I18nService
|
private readonly explorerIconService: ExplorerIconService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -128,30 +127,30 @@ export class DocDisplayMetaService extends Service {
|
|||||||
const iconSet = icons[options?.type ?? 'rc'];
|
const iconSet = icons[options?.type ?? 'rc'];
|
||||||
|
|
||||||
return LiveData.computed(get => {
|
return LiveData.computed(get => {
|
||||||
const enableEmojiIcon =
|
const enableEmojiIcon = options?.enableEmojiIcon !== false;
|
||||||
get(this.featureFlagService.flags.enable_emoji_doc_icon.$) &&
|
|
||||||
options?.enableEmojiIcon !== false;
|
|
||||||
const doc = get(this.docsService.list.doc$(docId));
|
const doc = get(this.docsService.list.doc$(docId));
|
||||||
const referenced = !!options?.reference;
|
const referenced = !!options?.reference;
|
||||||
const titleAlias = referenced ? options?.title : undefined;
|
const titleAlias = referenced ? options?.title : undefined;
|
||||||
const originalTitle = doc ? get(doc.title$) : '';
|
// const originalTitle = doc ? get(doc.title$) : '';
|
||||||
// link to journal doc
|
// link to journal doc
|
||||||
const journalDateString = get(this.journalService.journalDate$(docId));
|
const journalDateString = get(this.journalService.journalDate$(docId));
|
||||||
const journalIcon = journalDateString
|
const journalIcon = journalDateString
|
||||||
? this.getJournalIcon(journalDateString, options)
|
? this.getJournalIcon(journalDateString, options)
|
||||||
: undefined;
|
: undefined;
|
||||||
const journalTitle = journalDateString
|
// const journalTitle = journalDateString
|
||||||
? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
|
// ? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
|
||||||
: undefined;
|
// : undefined;
|
||||||
const title = titleAlias ?? journalTitle ?? originalTitle;
|
// const title = titleAlias ?? journalTitle ?? originalTitle;
|
||||||
const mode = doc ? get(doc.primaryMode$) : undefined;
|
const mode = doc ? get(doc.primaryMode$) : undefined;
|
||||||
const finalMode = options?.mode ?? mode ?? 'page';
|
const finalMode = options?.mode ?? mode ?? 'page';
|
||||||
const referenceToNode = !!(referenced && options.referenceToNode);
|
const referenceToNode = !!(referenced && options.referenceToNode);
|
||||||
|
|
||||||
// emoji title
|
// emoji title
|
||||||
if (enableEmojiIcon && title) {
|
if (enableEmojiIcon) {
|
||||||
const { emoji } = extractEmojiIcon(title);
|
// const { emoji } = extractEmojiIcon(title);
|
||||||
if (emoji) return () => emoji;
|
// if (emoji) return () => emoji;
|
||||||
|
const icon = get(this.explorerIconService.icon$('doc', docId));
|
||||||
|
if (icon && icon.type === 'emoji') return () => icon.icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
// title alias
|
// title alias
|
||||||
@@ -176,10 +175,6 @@ export class DocDisplayMetaService extends Service {
|
|||||||
|
|
||||||
title$(docId: string, options?: DocDisplayTitleOptions) {
|
title$(docId: string, options?: DocDisplayTitleOptions) {
|
||||||
return LiveData.computed(get => {
|
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 lng = get(this.i18nService.i18n.currentLanguageKey$);
|
||||||
const doc = get(this.docsService.list.doc$(docId));
|
const doc = get(this.docsService.list.doc$(docId));
|
||||||
const referenced = !!options?.reference;
|
const referenced = !!options?.reference;
|
||||||
@@ -190,20 +185,17 @@ export class DocDisplayMetaService extends Service {
|
|||||||
const journalTitle = journalDateString
|
const journalTitle = journalDateString
|
||||||
? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
|
? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
|
||||||
: undefined;
|
: undefined;
|
||||||
const title = titleAlias ?? journalTitle ?? originalTitle;
|
// const title = titleAlias ?? journalTitle ?? originalTitle;
|
||||||
|
|
||||||
// emoji title
|
// emoji title
|
||||||
if (enableEmojiIcon && title) {
|
// if (enableEmojiIcon && title) {
|
||||||
const { rest } = extractEmojiIcon(title);
|
// // When the title has only one emoji character,
|
||||||
if (rest) return rest;
|
// // if it's a journal document, the date should be displayed.
|
||||||
|
// const journalDateString = get(this.journalService.journalDate$(docId));
|
||||||
// When the title has only one emoji character,
|
// if (journalDateString) {
|
||||||
// if it's a journal document, the date should be displayed.
|
// return i18nTime(journalDateString, { absolute: { accuracy: 'day' } });
|
||||||
const journalDateString = get(this.journalService.journalDate$(docId));
|
// }
|
||||||
if (journalDateString) {
|
// }
|
||||||
return i18nTime(journalDateString, { absolute: { accuracy: 'day' } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// title alias
|
// title alias
|
||||||
if (titleAlias) return titleAlias;
|
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 { configureDocsSearchModule } from './docs-search';
|
||||||
import { configureEditorModule } from './editor';
|
import { configureEditorModule } from './editor';
|
||||||
import { configureEditorSettingModule } from './editor-setting';
|
import { configureEditorSettingModule } from './editor-setting';
|
||||||
|
import { configureExplorerIconModule } from './explorer-icon';
|
||||||
import { configureFavoriteModule } from './favorite';
|
import { configureFavoriteModule } from './favorite';
|
||||||
import { configureFeatureFlagModule } from './feature-flag';
|
import { configureFeatureFlagModule } from './feature-flag';
|
||||||
import { configureGlobalContextModule } from './global-context';
|
import { configureGlobalContextModule } from './global-context';
|
||||||
@@ -85,6 +86,7 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configureTelemetryModule(framework);
|
configureTelemetryModule(framework);
|
||||||
configurePDFModule(framework);
|
configurePDFModule(framework);
|
||||||
configurePeekViewModule(framework);
|
configurePeekViewModule(framework);
|
||||||
|
configureExplorerIconModule(framework);
|
||||||
configureDocDisplayMetaModule(framework);
|
configureDocDisplayMetaModule(framework);
|
||||||
configureQuickSearchModule(framework);
|
configureQuickSearchModule(framework);
|
||||||
configureDocsSearchModule(framework);
|
configureDocsSearchModule(framework);
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import Graphemer from 'graphemer';
|
|||||||
const emojiRe =
|
const emojiRe =
|
||||||
/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/g;
|
/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use ExplorerIconService instead
|
||||||
|
*/
|
||||||
export function extractEmojiIcon(text: string) {
|
export function extractEmojiIcon(text: string) {
|
||||||
emojiRe.lastIndex = 0;
|
emojiRe.lastIndex = 0;
|
||||||
const match = emojiRe.exec(text);
|
const match = emojiRe.exec(text);
|
||||||
|
|||||||
@@ -8314,6 +8314,10 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Cut`
|
* `Cut`
|
||||||
*/
|
*/
|
||||||
["com.affine.context-menu.cut"](): string;
|
["com.affine.context-menu.cut"](): string;
|
||||||
|
/**
|
||||||
|
* `Add icon`
|
||||||
|
*/
|
||||||
|
["com.affine.docIconPicker.placeholder"](): string;
|
||||||
/**
|
/**
|
||||||
* `An internal error occurred.`
|
* `An internal error occurred.`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2086,6 +2086,7 @@
|
|||||||
"com.affine.context-menu.copy": "Copy",
|
"com.affine.context-menu.copy": "Copy",
|
||||||
"com.affine.context-menu.paste": "Paste",
|
"com.affine.context-menu.paste": "Paste",
|
||||||
"com.affine.context-menu.cut": "Cut",
|
"com.affine.context-menu.cut": "Cut",
|
||||||
|
"com.affine.docIconPicker.placeholder": "Add icon",
|
||||||
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
|
||||||
"error.NETWORK_ERROR": "Network error.",
|
"error.NETWORK_ERROR": "Network error.",
|
||||||
"error.TOO_MANY_REQUEST": "Too many requests.",
|
"error.TOO_MANY_REQUEST": "Too many requests.",
|
||||||
|
|||||||
@@ -821,64 +821,64 @@ test.describe('Customize linked doc title and description', () => {
|
|||||||
await expect(cardDescription).toBeHidden();
|
await expect(cardDescription).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show emoji doc icon in normal document', async ({ page }) => {
|
// test('should show emoji doc icon in normal document', async ({ page }) => {
|
||||||
await enableEmojiDocIcon(page);
|
// await enableEmojiDocIcon(page);
|
||||||
|
|
||||||
await clickNewPageButton(page);
|
// await clickNewPageButton(page);
|
||||||
const title = getBlockSuiteEditorTitle(page);
|
// const title = getBlockSuiteEditorTitle(page);
|
||||||
await title.click();
|
// await title.click();
|
||||||
|
|
||||||
await page.keyboard.press('Enter');
|
// await page.keyboard.press('Enter');
|
||||||
await createLinkedPage(page, 'Test Page');
|
// 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');
|
// const inlineLink = page.locator('affine-reference');
|
||||||
await inlineLink.hover();
|
// await inlineLink.hover();
|
||||||
|
|
||||||
// Edits title
|
// // Edits title
|
||||||
await toolbar.getByRole('button', { name: 'Edit' }).click();
|
// await toolbar.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
|
||||||
// Title alias
|
// // Title alias
|
||||||
await page.keyboard.type('🦀hello');
|
// await page.keyboard.type('🦀hello');
|
||||||
await page.keyboard.press('Enter');
|
// await page.keyboard.press('Enter');
|
||||||
|
|
||||||
const a = inlineLink.locator('a');
|
// const a = inlineLink.locator('a');
|
||||||
|
|
||||||
await expect(a).toHaveText('🦀hello');
|
// await expect(a).toHaveText('🦀hello');
|
||||||
await expect(a.locator('svg')).toBeHidden();
|
// await expect(a.locator('svg')).toBeHidden();
|
||||||
await expect(a.locator('.affine-reference-title')).toHaveText('hello');
|
// await expect(a.locator('.affine-reference-title')).toHaveText('hello');
|
||||||
});
|
// });
|
||||||
|
|
||||||
test('should show emoji doc icon in journal document', async ({ page }) => {
|
// test('should show emoji doc icon in journal document', async ({ page }) => {
|
||||||
await enableEmojiDocIcon(page);
|
// await enableEmojiDocIcon(page);
|
||||||
|
|
||||||
await clickNewPageButton(page);
|
// await clickNewPageButton(page);
|
||||||
const title = getBlockSuiteEditorTitle(page);
|
// const title = getBlockSuiteEditorTitle(page);
|
||||||
await title.click();
|
// await title.click();
|
||||||
|
|
||||||
await page.keyboard.press('Enter');
|
// await page.keyboard.press('Enter');
|
||||||
await createTodayPage(page);
|
// 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');
|
// const inlineLink = page.locator('affine-reference');
|
||||||
await inlineLink.hover();
|
// await inlineLink.hover();
|
||||||
|
|
||||||
// Edits title
|
// // Edits title
|
||||||
await toolbar.getByRole('button', { name: 'Edit' }).click();
|
// await toolbar.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
|
||||||
// Title alias
|
// // Title alias
|
||||||
await page.keyboard.type('🦀');
|
// await page.keyboard.type('🦀');
|
||||||
await page.keyboard.press('Enter');
|
// await page.keyboard.press('Enter');
|
||||||
|
|
||||||
const a = inlineLink.locator('a');
|
// const a = inlineLink.locator('a');
|
||||||
|
|
||||||
const year = String(new Date().getFullYear());
|
// const year = String(new Date().getFullYear());
|
||||||
await expect(a).toContainText('🦀');
|
// await expect(a).toContainText('🦀');
|
||||||
await expect(a.locator('svg')).toBeHidden();
|
// await expect(a.locator('svg')).toBeHidden();
|
||||||
await expect(a.locator('.affine-reference-title')).toContainText(year);
|
// await expect(a.locator('.affine-reference-title')).toContainText(year);
|
||||||
});
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should save open doc mode of internal links', async ({ page }) => {
|
test('should save open doc mode of internal links', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user