mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-11 22:18:54 +08:00
@@ -61,7 +61,7 @@ export const InviteModal = ({
|
|||||||
confirmButtonOptions={{
|
confirmButtonOptions={{
|
||||||
loading: isMutating,
|
loading: isMutating,
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
['data-testid' as string]: 'confirm-enable-affine-cloud-button',
|
'data-testid': 'confirm-enable-affine-cloud-button',
|
||||||
}}
|
}}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export interface ButtonProps
|
|||||||
tooltip?: TooltipProps['content'];
|
tooltip?: TooltipProps['content'];
|
||||||
tooltipShortcut?: TooltipProps['shortcut'];
|
tooltipShortcut?: TooltipProps['shortcut'];
|
||||||
tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>;
|
tooltipOptions?: Partial<Omit<TooltipProps, 'content' | 'shortcut'>>;
|
||||||
|
[key: `data-${string}`]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconSlot = ({
|
const IconSlot = ({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type RowInputProps = {
|
|||||||
type?: HTMLInputElement['type'];
|
type?: HTMLInputElement['type'];
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
onEnter?: () => void;
|
onEnter?: () => void;
|
||||||
|
[key: `data-${string}`]: string;
|
||||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>;
|
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'onBlur'>;
|
||||||
|
|
||||||
// RowInput component that is used in the selector layout for search input
|
// RowInput component that is used in the selector layout for search input
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export const DesktopMenuSub = ({
|
|||||||
} = {},
|
} = {},
|
||||||
}: MenuSubProps) => {
|
}: MenuSubProps) => {
|
||||||
const { className, children, otherProps } = useMenuItem({
|
const { className, children, otherProps } = useMenuItem({
|
||||||
...triggerOptions,
|
|
||||||
children: propsChildren,
|
children: propsChildren,
|
||||||
suffixIcon: <ArrowRightSmallIcon />,
|
suffixIcon: <ArrowRightSmallIcon />,
|
||||||
|
...triggerOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ export {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { Menu, MenuItem, MenuSeparator, MenuSub, MenuTrigger };
|
export { Menu, MenuItem, MenuSeparator, MenuSub, MenuTrigger };
|
||||||
|
export * from './mobile/hook';
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export interface MenuItemProps
|
|||||||
export interface MenuSubProps {
|
export interface MenuSubProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
items: ReactNode;
|
items: ReactNode;
|
||||||
triggerOptions?: Omit<MenuItemProps, 'onSelect' | 'children' | 'suffixIcon'>;
|
triggerOptions?: Omit<MenuItemProps, 'onSelect' | 'children'> & {
|
||||||
|
[key: `data-${string}`]: string;
|
||||||
|
};
|
||||||
portalOptions?: Omit<DropdownMenuPortalProps, 'children'>;
|
portalOptions?: Omit<DropdownMenuPortalProps, 'children'>;
|
||||||
subOptions?: Omit<DropdownMenuSubProps, 'children'>;
|
subOptions?: Omit<DropdownMenuSubProps, 'children'>;
|
||||||
subContentOptions?: Omit<DropdownMenuSubContentProps, 'children'>;
|
subContentOptions?: Omit<DropdownMenuSubContentProps, 'children'>;
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
import type { MenuSubProps } from '../menu.types';
|
import type { MenuSubProps } from '../menu.types';
|
||||||
|
|
||||||
export type SubMenuContent = {
|
export type SubMenuContent = {
|
||||||
|
/**
|
||||||
|
* Customize submenu's title
|
||||||
|
* @default "Back"
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
items: ReactNode;
|
items: ReactNode;
|
||||||
contentOptions?: MenuSubProps['subContentOptions'];
|
contentOptions?: MenuSubProps['subContentOptions'];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useCallback, useContext } from 'react';
|
||||||
|
|
||||||
|
import { MobileMenuContext } from './context';
|
||||||
|
|
||||||
|
export const useMobileMenuController = () => {
|
||||||
|
const context = useContext(MobileMenuContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **A workaround to close mobile menu manually**
|
||||||
|
* By default, it will close automatically when `MenuItem` clicked.
|
||||||
|
* For custom menu content, you can use this method to close the menu.
|
||||||
|
*/
|
||||||
|
const close = useCallback(() => {
|
||||||
|
context.setOpen?.(false);
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
return { close };
|
||||||
|
};
|
||||||
@@ -134,9 +134,9 @@ export const MobileMenu = ({
|
|||||||
className={styles.backButton}
|
className={styles.backButton}
|
||||||
prefix={<ArrowLeftSmallIcon />}
|
prefix={<ArrowLeftSmallIcon />}
|
||||||
onClick={() => setSubMenus(prev => prev.slice(0, index))}
|
onClick={() => setSubMenus(prev => prev.slice(0, index))}
|
||||||
prefixStyle={{ width: 20, height: 20 }}
|
prefixStyle={{ width: 24, height: 24 }}
|
||||||
>
|
>
|
||||||
{t['com.affine.backButton']()}
|
{sub.title || t['com.affine.backButton']()}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{sub.items}
|
{sub.items}
|
||||||
|
|||||||
@@ -7,19 +7,20 @@ import { useMenuItem } from '../use-menu-item';
|
|||||||
import { MobileMenuContext } from './context';
|
import { MobileMenuContext } from './context';
|
||||||
|
|
||||||
export const MobileMenuSub = ({
|
export const MobileMenuSub = ({
|
||||||
|
title,
|
||||||
children: propsChildren,
|
children: propsChildren,
|
||||||
items,
|
items,
|
||||||
triggerOptions,
|
triggerOptions,
|
||||||
subContentOptions: contentOptions = {},
|
subContentOptions: contentOptions = {},
|
||||||
}: MenuSubProps) => {
|
}: MenuSubProps & { title?: string }) => {
|
||||||
const {
|
const {
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
otherProps: { onClick, ...otherTriggerOptions },
|
otherProps: { onClick, ...otherTriggerOptions },
|
||||||
} = useMenuItem({
|
} = useMenuItem({
|
||||||
...triggerOptions,
|
|
||||||
children: propsChildren,
|
children: propsChildren,
|
||||||
suffixIcon: <ArrowRightSmallPlusIcon />,
|
suffixIcon: <ArrowRightSmallPlusIcon />,
|
||||||
|
...triggerOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,6 +28,7 @@ export const MobileMenuSub = ({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
items={items}
|
items={items}
|
||||||
subContentOptions={contentOptions}
|
subContentOptions={contentOptions}
|
||||||
|
title={title}
|
||||||
>
|
>
|
||||||
<div className={className} {...otherTriggerOptions}>
|
<div className={className} {...otherTriggerOptions}>
|
||||||
{children}
|
{children}
|
||||||
@@ -36,19 +38,23 @@ export const MobileMenuSub = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenuSubRaw = ({
|
export const MobileMenuSubRaw = ({
|
||||||
|
title,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
items,
|
items,
|
||||||
subContentOptions: contentOptions = {},
|
subContentOptions: contentOptions = {},
|
||||||
}: MenuSubProps & { onClick?: (e: MouseEvent<HTMLDivElement>) => void }) => {
|
}: MenuSubProps & {
|
||||||
|
onClick?: (e: MouseEvent<HTMLDivElement>) => void;
|
||||||
|
title?: string;
|
||||||
|
}) => {
|
||||||
const { setSubMenus } = useContext(MobileMenuContext);
|
const { setSubMenus } = useContext(MobileMenuContext);
|
||||||
|
|
||||||
const onItemClick = useCallback(
|
const onItemClick = useCallback(
|
||||||
(e: MouseEvent<HTMLDivElement>) => {
|
(e: MouseEvent<HTMLDivElement>) => {
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
setSubMenus(prev => [...prev, { items, contentOptions }]);
|
setSubMenus(prev => [...prev, { items, contentOptions, title }]);
|
||||||
},
|
},
|
||||||
[contentOptions, items, onClick, setSubMenus]
|
[contentOptions, items, onClick, setSubMenus, title]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <Slot onClick={onItemClick}>{children}</Slot>;
|
return <Slot onClick={onItemClick}>{children}</Slot>;
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ export const WorkspaceDeleteModal = ({
|
|||||||
confirmButtonOptions={{
|
confirmButtonOptions={{
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
disabled: !allowDelete,
|
disabled: !allowDelete,
|
||||||
['data-testid' as string]: 'delete-workspace-confirm-button',
|
'data-testid': 'delete-workspace-confirm-button',
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export const Export = ({ exportHandler, className, pageMode }: ExportProps) => {
|
|||||||
triggerOptions={{
|
triggerOptions={{
|
||||||
className: transitionStyle,
|
className: transitionStyle,
|
||||||
prefixIcon: <ExportIcon />,
|
prefixIcon: <ExportIcon />,
|
||||||
['data-testid' as string]: 'export-menu',
|
'data-testid': 'export-menu',
|
||||||
}}
|
}}
|
||||||
subOptions={{
|
subOptions={{
|
||||||
onOpenChange: handleExportMenuOpenChange,
|
onOpenChange: handleExportMenuOpenChange,
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export const Snapshot = ({ className }: SnapshotProps) => {
|
|||||||
triggerOptions={{
|
triggerOptions={{
|
||||||
className: transitionStyle,
|
className: transitionStyle,
|
||||||
prefixIcon: <ToneIcon />,
|
prefixIcon: <ToneIcon />,
|
||||||
['data-testid' as string]: 'snapshot-menu',
|
'data-testid': 'snapshot-menu',
|
||||||
}}
|
}}
|
||||||
subOptions={{}}
|
subOptions={{}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
|
||||||
|
import {
|
||||||
|
RenameDialog,
|
||||||
|
type RenameDialogProps,
|
||||||
|
RenameSubMenu,
|
||||||
|
type RenameSubMenuProps,
|
||||||
|
} from '../../../rename';
|
||||||
|
|
||||||
|
export const CollectionRenameSubMenu = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
...props
|
||||||
|
}: RenameSubMenuProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<RenameSubMenu
|
||||||
|
title={title || t['com.affine.m.explorer.collection.rename-menu-title']()}
|
||||||
|
text={text || t['com.affine.m.explorer.collection.rename']()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CollectionDesc = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
return t['com.affine.collection.emptyCollectionDescription']();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CollectionRenameDialog = ({
|
||||||
|
title,
|
||||||
|
confirmText,
|
||||||
|
...props
|
||||||
|
}: RenameDialogProps) => {
|
||||||
|
return (
|
||||||
|
<RenameDialog
|
||||||
|
title={title}
|
||||||
|
confirmText={confirmText}
|
||||||
|
{...props}
|
||||||
|
descRenderer={CollectionDesc}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -52,23 +52,6 @@ export const ExplorerCollectionNode = ({
|
|||||||
|
|
||||||
const collection = useLiveData(collectionService.collection$(collectionId));
|
const collection = useLiveData(collectionService.collection$(collectionId));
|
||||||
|
|
||||||
const handleRename = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
if (collection && collection.name !== name) {
|
|
||||||
collectionService.updateCollection(collectionId, () => ({
|
|
||||||
...collection,
|
|
||||||
name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
track.$.navigationPanel.organize.renameOrganizeItem({
|
|
||||||
type: 'collection',
|
|
||||||
});
|
|
||||||
notify.success({ message: t['com.affine.toastMessage.rename']() });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[collection, collectionId, collectionService, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenCollapsed = useCallback(() => {
|
const handleOpenCollapsed = useCallback(() => {
|
||||||
setCollapsed(false);
|
setCollapsed(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -105,7 +88,7 @@ export const ExplorerCollectionNode = ({
|
|||||||
return [...additionalOperations, ...collectionOperations];
|
return [...additionalOperations, ...collectionOperations];
|
||||||
}
|
}
|
||||||
return collectionOperations;
|
return collectionOperations;
|
||||||
}, [collectionOperations, additionalOperations]);
|
}, [additionalOperations, collectionOperations]);
|
||||||
|
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
return null;
|
return null;
|
||||||
@@ -115,12 +98,10 @@ export const ExplorerCollectionNode = ({
|
|||||||
<ExplorerTreeNode
|
<ExplorerTreeNode
|
||||||
icon={CollectionIcon}
|
icon={CollectionIcon}
|
||||||
name={collection.name || t['Untitled']()}
|
name={collection.name || t['Untitled']()}
|
||||||
renameable
|
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
setCollapsed={setCollapsed}
|
setCollapsed={setCollapsed}
|
||||||
to={`/collection/${collection.id}`}
|
to={`/collection/${collection.id}`}
|
||||||
active={active}
|
active={active}
|
||||||
onRename={handleRename}
|
|
||||||
operations={finalOperations}
|
operations={finalOperations}
|
||||||
data-testid={`explorer-collection-${collectionId}`}
|
data-testid={`explorer-collection-${collectionId}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuSeparator,
|
MenuSeparator,
|
||||||
|
notify,
|
||||||
useConfirmModal,
|
useConfirmModal,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
||||||
@@ -28,6 +29,8 @@ import {
|
|||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { CollectionRenameSubMenu } from './dialog';
|
||||||
|
|
||||||
export const useExplorerCollectionNodeOperations = (
|
export const useExplorerCollectionNodeOperations = (
|
||||||
collectionId: string,
|
collectionId: string,
|
||||||
onOpenCollapsed: () => void,
|
onOpenCollapsed: () => void,
|
||||||
@@ -113,6 +116,24 @@ export const useExplorerCollectionNodeOperations = (
|
|||||||
onOpenEdit();
|
onOpenEdit();
|
||||||
}, [onOpenEdit]);
|
}, [onOpenEdit]);
|
||||||
|
|
||||||
|
const handleRename = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
const collection = collectionService.collection$(collectionId).value;
|
||||||
|
if (collection && collection.name !== name) {
|
||||||
|
collectionService.updateCollection(collectionId, () => ({
|
||||||
|
...collection,
|
||||||
|
name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
track.$.navigationPanel.organize.renameOrganizeItem({
|
||||||
|
type: 'collection',
|
||||||
|
});
|
||||||
|
notify.success({ message: t['com.affine.toastMessage.rename']() });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[collectionId, collectionService, t]
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
favorite,
|
favorite,
|
||||||
@@ -122,6 +143,7 @@ export const useExplorerCollectionNodeOperations = (
|
|||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
handleShowEdit,
|
handleShowEdit,
|
||||||
handleToggleFavoriteCollection,
|
handleToggleFavoriteCollection,
|
||||||
|
handleRename,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
favorite,
|
favorite,
|
||||||
@@ -129,6 +151,7 @@ export const useExplorerCollectionNodeOperations = (
|
|||||||
handleDeleteCollection,
|
handleDeleteCollection,
|
||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
|
handleRename,
|
||||||
handleShowEdit,
|
handleShowEdit,
|
||||||
handleToggleFavoriteCollection,
|
handleToggleFavoriteCollection,
|
||||||
]
|
]
|
||||||
@@ -154,6 +177,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
|||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
handleShowEdit,
|
handleShowEdit,
|
||||||
handleToggleFavoriteCollection,
|
handleToggleFavoriteCollection,
|
||||||
|
handleRename,
|
||||||
} = useExplorerCollectionNodeOperations(
|
} = useExplorerCollectionNodeOperations(
|
||||||
collectionId,
|
collectionId,
|
||||||
onOpenCollapsed,
|
onOpenCollapsed,
|
||||||
@@ -177,6 +201,14 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
index: 10,
|
||||||
|
view: <CollectionRenameSubMenu onConfirm={handleRename} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 11,
|
||||||
|
view: <MenuSeparator />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
index: 99,
|
index: 99,
|
||||||
view: (
|
view: (
|
||||||
@@ -256,6 +288,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
|||||||
handleDeleteCollection,
|
handleDeleteCollection,
|
||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
|
handleRename,
|
||||||
handleShowEdit,
|
handleShowEdit,
|
||||||
handleToggleFavoriteCollection,
|
handleToggleFavoriteCollection,
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
|
||||||
|
import { RenameSubMenu, type RenameSubMenuProps } from '../../../rename';
|
||||||
|
|
||||||
|
export const DocRenameSubMenu = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
...props
|
||||||
|
}: RenameSubMenuProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<RenameSubMenu
|
||||||
|
title={title || t['com.affine.m.explorer.doc.rename']()}
|
||||||
|
text={text || t['com.affine.m.explorer.doc.rename']()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Loading } from '@affine/component';
|
import { Loading } from '@affine/component';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
|
||||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||||
import { DocInfoService } from '@affine/core/modules/doc-info';
|
import { DocInfoService } from '@affine/core/modules/doc-info';
|
||||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||||
import type { NodeOperation } from '@affine/core/modules/explorer';
|
import type { NodeOperation } from '@affine/core/modules/explorer';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import track from '@affine/track';
|
|
||||||
import {
|
import {
|
||||||
DocsService,
|
DocsService,
|
||||||
FeatureFlagService,
|
FeatureFlagService,
|
||||||
@@ -92,14 +90,6 @@ export const ExplorerDocNode = ({
|
|||||||
);
|
);
|
||||||
}, [indexerLoading]);
|
}, [indexerLoading]);
|
||||||
|
|
||||||
const handleRename = useAsyncCallback(
|
|
||||||
async (newName: string) => {
|
|
||||||
await docsService.changeDocTitle(docId, newName);
|
|
||||||
track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' });
|
|
||||||
},
|
|
||||||
[docId, docsService]
|
|
||||||
);
|
|
||||||
|
|
||||||
const docInfoModal = useService(DocInfoService).modal;
|
const docInfoModal = useService(DocInfoService).modal;
|
||||||
const option = useMemo(
|
const option = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -126,7 +116,6 @@ export const ExplorerDocNode = ({
|
|||||||
<ExplorerTreeNode
|
<ExplorerTreeNode
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
name={t.t(docTitle)}
|
name={t.t(docTitle)}
|
||||||
renameable
|
|
||||||
extractEmojiAsIcon={enableEmojiIcon}
|
extractEmojiAsIcon={enableEmojiIcon}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
setCollapsed={setCollapsed}
|
setCollapsed={setCollapsed}
|
||||||
@@ -140,7 +129,6 @@ export const ExplorerDocNode = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onRename={handleRename}
|
|
||||||
operations={finalOperations}
|
operations={finalOperations}
|
||||||
data-testid={`explorer-doc-${docId}`}
|
data-testid={`explorer-doc-${docId}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { DocRenameSubMenu } from './dialog';
|
||||||
|
|
||||||
export const useExplorerDocNodeOperations = (
|
export const useExplorerDocNodeOperations = (
|
||||||
docId: string,
|
docId: string,
|
||||||
options: {
|
options: {
|
||||||
@@ -135,6 +137,14 @@ export const useExplorerDocNodeOperations = (
|
|||||||
});
|
});
|
||||||
}, [docId, compatibleFavoriteItemsAdapter]);
|
}, [docId, compatibleFavoriteItemsAdapter]);
|
||||||
|
|
||||||
|
const handleRename = useAsyncCallback(
|
||||||
|
async (newName: string) => {
|
||||||
|
await docsService.changeDocTitle(docId, newName);
|
||||||
|
track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' });
|
||||||
|
},
|
||||||
|
[docId, docsService]
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
favorite,
|
favorite,
|
||||||
@@ -145,6 +155,7 @@ export const useExplorerDocNodeOperations = (
|
|||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleMoveToTrash,
|
handleMoveToTrash,
|
||||||
handleOpenInfoModal,
|
handleOpenInfoModal,
|
||||||
|
handleRename,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
favorite,
|
favorite,
|
||||||
@@ -154,6 +165,7 @@ export const useExplorerDocNodeOperations = (
|
|||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
handleOpenInfoModal,
|
handleOpenInfoModal,
|
||||||
|
handleRename,
|
||||||
handleToggleFavoriteDoc,
|
handleToggleFavoriteDoc,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -177,8 +189,12 @@ export const useExplorerDocNodeOperationsMenu = (
|
|||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleMoveToTrash,
|
handleMoveToTrash,
|
||||||
handleOpenInfoModal,
|
handleOpenInfoModal,
|
||||||
|
handleRename,
|
||||||
} = useExplorerDocNodeOperations(docId, options);
|
} = useExplorerDocNodeOperations(docId, options);
|
||||||
|
|
||||||
|
const docService = useService(DocsService);
|
||||||
|
const docRecord = useLiveData(docService.list.doc$(docId));
|
||||||
|
const title = useLiveData(docRecord?.title$);
|
||||||
const enableMultiView = useLiveData(
|
const enableMultiView = useLiveData(
|
||||||
featureFlagService.flags.enable_multi_view.$
|
featureFlagService.flags.enable_multi_view.$
|
||||||
);
|
);
|
||||||
@@ -197,6 +213,14 @@ export const useExplorerDocNodeOperationsMenu = (
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
index: 10,
|
||||||
|
view: <DocRenameSubMenu onConfirm={handleRename} initialName={title} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 11,
|
||||||
|
view: <MenuSeparator />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
index: 50,
|
index: 50,
|
||||||
view: (
|
view: (
|
||||||
@@ -289,8 +313,10 @@ export const useExplorerDocNodeOperationsMenu = (
|
|||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
handleOpenInfoModal,
|
handleOpenInfoModal,
|
||||||
|
handleRename,
|
||||||
handleToggleFavoriteDoc,
|
handleToggleFavoriteDoc,
|
||||||
t,
|
t,
|
||||||
|
title,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { EditIcon } from '@blocksuite/icons/rc';
|
||||||
|
|
||||||
|
import type { RenameDialogProps, RenameSubMenuProps } from '../../../rename';
|
||||||
|
import { RenameDialog, RenameSubMenu } from '../../../rename';
|
||||||
|
|
||||||
|
export const FolderCreateTip = ({
|
||||||
|
input,
|
||||||
|
parentName,
|
||||||
|
}: {
|
||||||
|
input?: string;
|
||||||
|
parentName?: string;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const parent = parentName
|
||||||
|
? parentName
|
||||||
|
: t['com.affine.m.explorer.folder.root']();
|
||||||
|
|
||||||
|
const tip = input
|
||||||
|
? t['com.affine.m.explorer.folder.new-tip-not-empty']({
|
||||||
|
value: input,
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
: t['com.affine.m.explorer.folder.new-tip-empty']({ parent });
|
||||||
|
|
||||||
|
return tip;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FolderRenameSubMenu = ({
|
||||||
|
title: propsTitle,
|
||||||
|
icon: propsIcon,
|
||||||
|
text: propsText,
|
||||||
|
...props
|
||||||
|
}: RenameSubMenuProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const title = propsTitle || t['com.affine.m.explorer.folder.rename']();
|
||||||
|
const icon = propsIcon || <EditIcon />;
|
||||||
|
const text = propsText || title;
|
||||||
|
|
||||||
|
return <RenameSubMenu title={title} icon={icon} text={text} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FolderRenameDialog = ({
|
||||||
|
title: propsTitle,
|
||||||
|
confirmText: propsConfirmText,
|
||||||
|
...props
|
||||||
|
}: RenameDialogProps & {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const title =
|
||||||
|
propsTitle || t['com.affine.m.explorer.folder.new-dialog-title']();
|
||||||
|
const confirmText =
|
||||||
|
propsConfirmText || t['com.affine.m.explorer.folder.rename-confirm']();
|
||||||
|
|
||||||
|
return <RenameDialog title={title} confirmText={confirmText} {...props} />;
|
||||||
|
};
|
||||||
@@ -47,14 +47,13 @@ import { ExplorerTreeNode } from '../../tree/node';
|
|||||||
import { ExplorerCollectionNode } from '../collection';
|
import { ExplorerCollectionNode } from '../collection';
|
||||||
import { ExplorerDocNode } from '../doc';
|
import { ExplorerDocNode } from '../doc';
|
||||||
import { ExplorerTagNode } from '../tag';
|
import { ExplorerTagNode } from '../tag';
|
||||||
|
import { FolderCreateTip, FolderRenameSubMenu } from './dialog';
|
||||||
import { FavoriteFolderOperation } from './operations';
|
import { FavoriteFolderOperation } from './operations';
|
||||||
|
|
||||||
export const ExplorerFolderNode = ({
|
export const ExplorerFolderNode = ({
|
||||||
nodeId,
|
nodeId,
|
||||||
defaultRenaming,
|
|
||||||
operations,
|
operations,
|
||||||
}: {
|
}: {
|
||||||
defaultRenaming?: boolean;
|
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
operations?:
|
operations?:
|
||||||
| NodeOperation[]
|
| NodeOperation[]
|
||||||
@@ -83,11 +82,7 @@ export const ExplorerFolderNode = ({
|
|||||||
|
|
||||||
if (type === 'folder') {
|
if (type === 'folder') {
|
||||||
return (
|
return (
|
||||||
<ExplorerFolderNodeFolder
|
<ExplorerFolderNodeFolder node={node} operations={additionalOperations} />
|
||||||
node={node}
|
|
||||||
defaultRenaming={defaultRenaming}
|
|
||||||
operations={additionalOperations}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
@@ -123,10 +118,8 @@ const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
|
|||||||
|
|
||||||
const ExplorerFolderNodeFolder = ({
|
const ExplorerFolderNodeFolder = ({
|
||||||
node,
|
node,
|
||||||
defaultRenaming,
|
|
||||||
operations: additionalOperations,
|
operations: additionalOperations,
|
||||||
}: {
|
}: {
|
||||||
defaultRenaming?: boolean;
|
|
||||||
node: FolderNode;
|
node: FolderNode;
|
||||||
operations?: NodeOperation[];
|
operations?: NodeOperation[];
|
||||||
}) => {
|
}) => {
|
||||||
@@ -144,7 +137,6 @@ const ExplorerFolderNodeFolder = ({
|
|||||||
featureFlagService.flags.enable_emoji_folder_icon.$
|
featureFlagService.flags.enable_emoji_folder_icon.$
|
||||||
);
|
);
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const [newFolderId, setNewFolderId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { createPage } = usePageHelper(
|
const { createPage } = usePageHelper(
|
||||||
workspaceService.workspace.docCollection
|
workspaceService.workspace.docCollection
|
||||||
@@ -182,15 +174,14 @@ const ExplorerFolderNodeFolder = ({
|
|||||||
setCollapsed(false);
|
setCollapsed(false);
|
||||||
}, [createPage, node]);
|
}, [createPage, node]);
|
||||||
|
|
||||||
const handleCreateSubfolder = useCallback(() => {
|
const handleCreateSubfolder = useCallback(
|
||||||
const newFolderId = node.createFolder(
|
(name: string) => {
|
||||||
t['com.affine.rootAppSidebar.organize.new-folders'](),
|
node.createFolder(name, node.indexAt('before'));
|
||||||
node.indexAt('before')
|
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
|
||||||
);
|
setCollapsed(false);
|
||||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
|
},
|
||||||
setCollapsed(false);
|
[node]
|
||||||
setNewFolderId(newFolderId);
|
);
|
||||||
}, [node, t]);
|
|
||||||
|
|
||||||
const handleAddToFolder = useCallback(
|
const handleAddToFolder = useCallback(
|
||||||
(type: 'doc' | 'collection' | 'tag') => {
|
(type: 'doc' | 'collection' | 'tag') => {
|
||||||
@@ -237,6 +228,13 @@ const ExplorerFolderNodeFolder = ({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createSubTipRenderer = useCallback(
|
||||||
|
({ input }: { input: string }) => {
|
||||||
|
return <FolderCreateTip input={input} parentName={name} />;
|
||||||
|
},
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
const folderOperations = useMemo(() => {
|
const folderOperations = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -254,12 +252,39 @@ const ExplorerFolderNodeFolder = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
index: 98,
|
||||||
|
view: (
|
||||||
|
<FolderRenameSubMenu
|
||||||
|
initialName={name}
|
||||||
|
onConfirm={handleRename}
|
||||||
|
menuProps={{
|
||||||
|
triggerOptions: { 'data-testid': 'rename-folder' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 99,
|
||||||
|
view: <MenuSeparator />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
index: 100,
|
index: 100,
|
||||||
view: (
|
view: (
|
||||||
<MenuItem prefixIcon={<FolderIcon />} onClick={handleCreateSubfolder}>
|
<FolderRenameSubMenu
|
||||||
{t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()}
|
text={t[
|
||||||
</MenuItem>
|
'com.affine.rootAppSidebar.organize.folder.create-subfolder'
|
||||||
|
]()}
|
||||||
|
title={t[
|
||||||
|
'com.affine.rootAppSidebar.organize.folder.create-subfolder'
|
||||||
|
]()}
|
||||||
|
onConfirm={handleCreateSubfolder}
|
||||||
|
descRenderer={createSubTipRenderer}
|
||||||
|
icon={<FolderIcon />}
|
||||||
|
menuProps={{
|
||||||
|
triggerOptions: { 'data-testid': 'create-subfolder' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -327,11 +352,14 @@ const ExplorerFolderNodeFolder = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [
|
}, [
|
||||||
|
createSubTipRenderer,
|
||||||
handleAddToFolder,
|
handleAddToFolder,
|
||||||
handleCreateSubfolder,
|
handleCreateSubfolder,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleNewDoc,
|
handleNewDoc,
|
||||||
node,
|
handleRename,
|
||||||
|
name,
|
||||||
|
node.id,
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -370,7 +398,6 @@ const ExplorerFolderNodeFolder = ({
|
|||||||
|
|
||||||
const handleCollapsedChange = useCallback((collapsed: boolean) => {
|
const handleCollapsedChange = useCallback((collapsed: boolean) => {
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
setNewFolderId(null); // reset new folder id to clear the renaming state
|
|
||||||
setCollapsed(true);
|
setCollapsed(true);
|
||||||
} else {
|
} else {
|
||||||
setCollapsed(false);
|
setCollapsed(false);
|
||||||
@@ -381,26 +408,25 @@ const ExplorerFolderNodeFolder = ({
|
|||||||
<ExplorerTreeNode
|
<ExplorerTreeNode
|
||||||
icon={ExplorerFolderIcon}
|
icon={ExplorerFolderIcon}
|
||||||
name={name}
|
name={name}
|
||||||
defaultRenaming={defaultRenaming}
|
|
||||||
renameable
|
|
||||||
extractEmojiAsIcon={enableEmojiIcon}
|
extractEmojiAsIcon={enableEmojiIcon}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
setCollapsed={handleCollapsedChange}
|
setCollapsed={handleCollapsedChange}
|
||||||
onRename={handleRename}
|
|
||||||
operations={finalOperations}
|
operations={finalOperations}
|
||||||
data-testid={`explorer-folder-${node.id}`}
|
data-testid={`explorer-folder-${node.id}`}
|
||||||
|
aria-label={name}
|
||||||
|
data-role="explorer-folder"
|
||||||
>
|
>
|
||||||
{children.map(child => (
|
{children.map(child => (
|
||||||
<ExplorerFolderNode
|
<ExplorerFolderNode
|
||||||
key={child.id}
|
key={child.id}
|
||||||
nodeId={child.id as string}
|
nodeId={child.id as string}
|
||||||
defaultRenaming={child.id === newFolderId}
|
|
||||||
operations={childrenOperations}
|
operations={childrenOperations}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<AddItemPlaceholder
|
<AddItemPlaceholder
|
||||||
label={t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
|
label={t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
|
||||||
onClick={() => handleAddToFolder('doc')}
|
onClick={() => handleAddToFolder('doc')}
|
||||||
|
data-testid="new-folder-in-folder-button"
|
||||||
/>
|
/>
|
||||||
</ExplorerTreeNode>
|
</ExplorerTreeNode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const colorDot = style({
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
':before': {
|
||||||
|
content: '""',
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
display: 'block',
|
||||||
|
background: 'currentColor',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const colorTrigger = style([
|
||||||
|
colorDot,
|
||||||
|
{
|
||||||
|
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||||
|
selectors: {
|
||||||
|
'&[data-active="true"]': {
|
||||||
|
borderColor: cssVarV2('input/border/active'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const colorsRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 12px',
|
||||||
|
height: 54,
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
// TODO(@CatsJuice): this animation is conflicting with sub-menu height detection
|
||||||
|
'&[data-enable-fold]': {
|
||||||
|
height: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all 0.23s ease',
|
||||||
|
},
|
||||||
|
'&[data-enable-fold][data-active="true"]': {
|
||||||
|
height: 54,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { type MenuSubProps, useMobileMenuController } from '@affine/component';
|
||||||
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type Dispatch,
|
||||||
|
type ReactNode,
|
||||||
|
type SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { RenameContent, RenameSubMenu } from '../../../rename';
|
||||||
|
import { RenameDialog } from '../../../rename/dialog';
|
||||||
|
import type { RenameContentProps } from '../../../rename/type';
|
||||||
|
import * as styles from './dialog.css';
|
||||||
|
|
||||||
|
const TagColorContext = createContext<{
|
||||||
|
colors: string[];
|
||||||
|
color: string;
|
||||||
|
setColor: Dispatch<SetStateAction<string>>;
|
||||||
|
show: boolean;
|
||||||
|
setShow: Dispatch<SetStateAction<boolean>>;
|
||||||
|
enableAnimation?: boolean;
|
||||||
|
}>({
|
||||||
|
color: '',
|
||||||
|
setColor: () => {},
|
||||||
|
colors: [],
|
||||||
|
show: false,
|
||||||
|
setShow: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ColorPickerTrigger = () => {
|
||||||
|
const { color, show, setShow } = useContext(TagColorContext);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="tag-color-picker-trigger"
|
||||||
|
className={styles.colorTrigger}
|
||||||
|
style={{ color }}
|
||||||
|
data-active={show}
|
||||||
|
onClick={() => setShow(prev => !prev)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ColorPickerSelect = () => {
|
||||||
|
const {
|
||||||
|
enableAnimation,
|
||||||
|
colors,
|
||||||
|
color: current,
|
||||||
|
setColor,
|
||||||
|
show,
|
||||||
|
} = useContext(TagColorContext);
|
||||||
|
|
||||||
|
if (!show && !enableAnimation) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="tag-color-picker-select"
|
||||||
|
className={styles.colorsRow}
|
||||||
|
data-active={show}
|
||||||
|
data-enable-fold={enableAnimation || undefined}
|
||||||
|
>
|
||||||
|
{colors.map(color => (
|
||||||
|
<div
|
||||||
|
key={color}
|
||||||
|
aria-checked={color === current}
|
||||||
|
onClick={() => setColor(color)}
|
||||||
|
className={styles.colorDot}
|
||||||
|
style={{ color }}
|
||||||
|
data-color={color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TagRenameContentProps extends Omit<RenameContentProps, 'onConfirm'> {
|
||||||
|
initialColor?: string;
|
||||||
|
onConfirm?: (name: string, color: string) => void;
|
||||||
|
enableAnimation?: boolean;
|
||||||
|
}
|
||||||
|
const TagRenameContent = ({
|
||||||
|
initialColor,
|
||||||
|
onConfirm,
|
||||||
|
enableAnimation,
|
||||||
|
...props
|
||||||
|
}: TagRenameContentProps) => {
|
||||||
|
const tagService = useService(TagService);
|
||||||
|
const colors = useMemo(() => {
|
||||||
|
return tagService.tagColors.map(([_, value]) => value);
|
||||||
|
}, [tagService.tagColors]);
|
||||||
|
|
||||||
|
const [color, setColor] = useState(
|
||||||
|
initialColor || tagService.randomTagColor()
|
||||||
|
);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
onConfirm?.(name, color);
|
||||||
|
},
|
||||||
|
[color, onConfirm]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagColorContext.Provider
|
||||||
|
value={{ colors, color, setColor, show, setShow, enableAnimation }}
|
||||||
|
>
|
||||||
|
<RenameContent
|
||||||
|
inputPrefixRenderer={ColorPickerTrigger}
|
||||||
|
inputBelowRenderer={ColorPickerSelect}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TagColorContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TagRenameDialogProps extends TagRenameContentProps {
|
||||||
|
title?: string;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
export const TagRenameDialog = ({
|
||||||
|
title: propsTitle,
|
||||||
|
confirmText: propsConfirmText,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
...props
|
||||||
|
}: TagRenameDialogProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const title = propsTitle || t['com.affine.m.explorer.tag.new-dialog-title']();
|
||||||
|
const confirmText =
|
||||||
|
propsConfirmText || t['com.affine.m.explorer.tag.rename-confirm']();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RenameDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
inputPrefixRenderer={ColorPickerTrigger}
|
||||||
|
title={title}
|
||||||
|
confirmText={confirmText}
|
||||||
|
>
|
||||||
|
<TagRenameContent {...props} />
|
||||||
|
</RenameDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TagRenameSubMenuProps {
|
||||||
|
tagId?: string;
|
||||||
|
title?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
text?: string;
|
||||||
|
onConfirm?: (name: string, color: string) => void;
|
||||||
|
menuProps?: Partial<MenuSubProps>;
|
||||||
|
}
|
||||||
|
export const TagRenameSubMenu = ({
|
||||||
|
tagId,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
text,
|
||||||
|
menuProps,
|
||||||
|
onConfirm,
|
||||||
|
}: TagRenameSubMenuProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { close } = useMobileMenuController();
|
||||||
|
const tagService = useService(TagService);
|
||||||
|
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
||||||
|
const tagName = useLiveData(tagRecord?.value$);
|
||||||
|
const tagColor = useLiveData(tagRecord?.color$);
|
||||||
|
|
||||||
|
const handleCloseAndConfirm = useCallback(
|
||||||
|
(name: string, color: string) => {
|
||||||
|
close();
|
||||||
|
onConfirm?.(name, color);
|
||||||
|
},
|
||||||
|
[close, onConfirm]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RenameSubMenu
|
||||||
|
title={title ?? t['com.affine.m.explorer.tag.rename-menu-title']()}
|
||||||
|
icon={icon}
|
||||||
|
text={text}
|
||||||
|
menuProps={menuProps}
|
||||||
|
>
|
||||||
|
<TagRenameContent
|
||||||
|
initialName={tagName}
|
||||||
|
initialColor={tagColor}
|
||||||
|
onConfirm={handleCloseAndConfirm}
|
||||||
|
/>
|
||||||
|
</RenameSubMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@ import type { NodeOperation } from '@affine/core/modules/explorer';
|
|||||||
import type { Tag } from '@affine/core/modules/tag';
|
import type { Tag } from '@affine/core/modules/tag';
|
||||||
import { TagService } from '@affine/core/modules/tag';
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import track from '@affine/track';
|
|
||||||
import {
|
import {
|
||||||
GlobalContextService,
|
GlobalContextService,
|
||||||
useLiveData,
|
useLiveData,
|
||||||
@@ -23,10 +22,8 @@ import * as styles from './styles.css';
|
|||||||
export const ExplorerTagNode = ({
|
export const ExplorerTagNode = ({
|
||||||
tagId,
|
tagId,
|
||||||
operations: additionalOperations,
|
operations: additionalOperations,
|
||||||
defaultRenaming,
|
|
||||||
}: {
|
}: {
|
||||||
tagId: string;
|
tagId: string;
|
||||||
defaultRenaming?: boolean;
|
|
||||||
operations?: NodeOperation[];
|
operations?: NodeOperation[];
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -47,6 +44,7 @@ export const ExplorerTagNode = ({
|
|||||||
return (
|
return (
|
||||||
<div className={clsx(styles.tagIconContainer, className)}>
|
<div className={clsx(styles.tagIconContainer, className)}>
|
||||||
<div
|
<div
|
||||||
|
data-testid="explorer-tag-icon-dot"
|
||||||
className={styles.tagIcon}
|
className={styles.tagIcon}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: tagColor,
|
backgroundColor: tagColor,
|
||||||
@@ -58,18 +56,6 @@ export const ExplorerTagNode = ({
|
|||||||
[tagColor]
|
[tagColor]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
|
||||||
(newName: string) => {
|
|
||||||
if (tagRecord && tagRecord.value$.value !== newName) {
|
|
||||||
tagRecord.rename(newName);
|
|
||||||
track.$.navigationPanel.organize.renameOrganizeItem({
|
|
||||||
type: 'tag',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[tagRecord]
|
|
||||||
);
|
|
||||||
|
|
||||||
const option = useMemo(
|
const option = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
openNodeCollapsed: () => setCollapsed(false),
|
openNodeCollapsed: () => setCollapsed(false),
|
||||||
@@ -94,15 +80,14 @@ export const ExplorerTagNode = ({
|
|||||||
<ExplorerTreeNode
|
<ExplorerTreeNode
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
name={tagName || t['Untitled']()}
|
name={tagName || t['Untitled']()}
|
||||||
renameable
|
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
setCollapsed={setCollapsed}
|
setCollapsed={setCollapsed}
|
||||||
to={`/tag/${tagId}`}
|
to={`/tag/${tagId}`}
|
||||||
active={active}
|
active={active}
|
||||||
defaultRenaming={defaultRenaming}
|
|
||||||
onRename={handleRename}
|
|
||||||
operations={finalOperations}
|
operations={finalOperations}
|
||||||
data-testid={`explorer-tag-${tagId}`}
|
data-testid={`explorer-tag-${tagId}`}
|
||||||
|
aria-label={tagName}
|
||||||
|
data-role="explorer-tag"
|
||||||
>
|
>
|
||||||
<ExplorerTagNodeDocs tag={tagRecord} onNewDoc={handleNewDoc} />
|
<ExplorerTagNodeDocs tag={tagRecord} onNewDoc={handleNewDoc} />
|
||||||
</ExplorerTreeNode>
|
</ExplorerTreeNode>
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ import { TagService } from '@affine/core/modules/tag';
|
|||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { track } from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import {
|
import { DeleteIcon, PlusIcon, SplitViewIcon } from '@blocksuite/icons/rc';
|
||||||
DeleteIcon,
|
|
||||||
OpenInNewIcon,
|
|
||||||
PlusIcon,
|
|
||||||
SplitViewIcon,
|
|
||||||
} from '@blocksuite/icons/rc';
|
|
||||||
import {
|
import {
|
||||||
DocsService,
|
DocsService,
|
||||||
FeatureFlagService,
|
FeatureFlagService,
|
||||||
@@ -23,6 +18,8 @@ import {
|
|||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { TagRenameSubMenu } from './dialog';
|
||||||
|
|
||||||
export const useExplorerTagNodeOperations = (
|
export const useExplorerTagNodeOperations = (
|
||||||
tagId: string,
|
tagId: string,
|
||||||
{
|
{
|
||||||
@@ -86,6 +83,37 @@ export const useExplorerTagNodeOperations = (
|
|||||||
track.$.navigationPanel.organize.openInNewTab({ type: 'tag' });
|
track.$.navigationPanel.organize.openInNewTab({ type: 'tag' });
|
||||||
}, [tagId, workbenchService]);
|
}, [tagId, workbenchService]);
|
||||||
|
|
||||||
|
const handleRename = useCallback(
|
||||||
|
(newName: string) => {
|
||||||
|
if (tagRecord && tagRecord.value$.value !== newName) {
|
||||||
|
tagRecord.rename(newName);
|
||||||
|
track.$.navigationPanel.organize.renameOrganizeItem({
|
||||||
|
type: 'tag',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tagRecord]
|
||||||
|
);
|
||||||
|
const handleChangeColor = useCallback(
|
||||||
|
(color: string) => {
|
||||||
|
if (tagRecord && tagRecord.color$.value !== color) {
|
||||||
|
tagRecord.changeColor(color);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tagRecord]
|
||||||
|
);
|
||||||
|
const handleChangeNameOrColor = useCallback(
|
||||||
|
(name?: string, color?: string) => {
|
||||||
|
if (name !== undefined) {
|
||||||
|
handleRename(name);
|
||||||
|
}
|
||||||
|
if (color !== undefined) {
|
||||||
|
handleChangeColor(color);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleChangeColor, handleRename]
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
favorite,
|
favorite,
|
||||||
@@ -94,13 +122,19 @@ export const useExplorerTagNodeOperations = (
|
|||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
handleToggleFavoriteTag,
|
handleToggleFavoriteTag,
|
||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
|
handleRename,
|
||||||
|
handleChangeColor,
|
||||||
|
handleChangeNameOrColor,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
favorite,
|
favorite,
|
||||||
|
handleChangeColor,
|
||||||
|
handleChangeNameOrColor,
|
||||||
handleMoveToTrash,
|
handleMoveToTrash,
|
||||||
handleNewDoc,
|
handleNewDoc,
|
||||||
handleOpenInNewTab,
|
handleOpenInNewTab,
|
||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
|
handleRename,
|
||||||
handleToggleFavoriteTag,
|
handleToggleFavoriteTag,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -122,7 +156,7 @@ export const useExplorerTagNodeOperationsMenu = (
|
|||||||
handleMoveToTrash,
|
handleMoveToTrash,
|
||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
handleToggleFavoriteTag,
|
handleToggleFavoriteTag,
|
||||||
handleOpenInNewTab,
|
handleChangeNameOrColor,
|
||||||
} = useExplorerTagNodeOperations(tagId, option);
|
} = useExplorerTagNodeOperations(tagId, option);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
@@ -131,21 +165,19 @@ export const useExplorerTagNodeOperationsMenu = (
|
|||||||
index: 0,
|
index: 0,
|
||||||
inline: true,
|
inline: true,
|
||||||
view: (
|
view: (
|
||||||
<IconButton
|
<IconButton size="16" onClick={handleNewDoc}>
|
||||||
size="16"
|
|
||||||
onClick={handleNewDoc}
|
|
||||||
tooltip={t['com.affine.rootAppSidebar.explorer.tag-add-tooltip']()}
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
index: 50,
|
index: 10,
|
||||||
view: (
|
view: (
|
||||||
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
<TagRenameSubMenu
|
||||||
{t['com.affine.workbench.tab.page-menu-open']()}
|
onConfirm={handleChangeNameOrColor}
|
||||||
</MenuItem>
|
tagId={tagId}
|
||||||
|
menuProps={{ triggerOptions: { 'data-testid': 'rename-tag' } }}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||||
@@ -196,12 +228,13 @@ export const useExplorerTagNodeOperationsMenu = (
|
|||||||
[
|
[
|
||||||
enableMultiView,
|
enableMultiView,
|
||||||
favorite,
|
favorite,
|
||||||
|
handleChangeNameOrColor,
|
||||||
handleMoveToTrash,
|
handleMoveToTrash,
|
||||||
handleNewDoc,
|
handleNewDoc,
|
||||||
handleOpenInNewTab,
|
|
||||||
handleOpenInSplitView,
|
handleOpenInSplitView,
|
||||||
handleToggleFavoriteTag,
|
handleToggleFavoriteTag,
|
||||||
t,
|
t,
|
||||||
|
tagId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+23
-26
@@ -1,4 +1,3 @@
|
|||||||
import { useEditCollectionName } from '@affine/core/components/page-list';
|
|
||||||
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
|
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
|
||||||
import { CollectionService } from '@affine/core/modules/collection';
|
import { CollectionService } from '@affine/core/modules/collection';
|
||||||
import { ExplorerService } from '@affine/core/modules/explorer';
|
import { ExplorerService } from '@affine/core/modules/explorer';
|
||||||
@@ -8,11 +7,12 @@ import { useI18n } from '@affine/i18n';
|
|||||||
import { track } from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import { useLiveData, useServices } from '@toeverything/infra';
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
||||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||||
import { ExplorerCollectionNode } from '../../nodes/collection';
|
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||||
|
import { CollectionRenameDialog } from '../../nodes/collection/dialog';
|
||||||
|
|
||||||
export const ExplorerCollections = () => {
|
export const ExplorerCollections = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -23,31 +23,22 @@ export const ExplorerCollections = () => {
|
|||||||
});
|
});
|
||||||
const explorerSection = explorerService.sections.collections;
|
const explorerSection = explorerService.sections.collections;
|
||||||
const collections = useLiveData(collectionService.collections$);
|
const collections = useLiveData(collectionService.collections$);
|
||||||
const { open: openCreateCollectionModel } = useEditCollectionName({
|
const [showCreateCollectionModal, setShowCreateCollectionModal] =
|
||||||
title: t['com.affine.editCollection.createCollection'](),
|
useState(false);
|
||||||
showTips: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCreateCollection = useCallback(() => {
|
const handleCreateCollection = useCallback(
|
||||||
openCreateCollectionModel('')
|
(name: string) => {
|
||||||
.then(name => {
|
setShowCreateCollectionModal(false);
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
collectionService.addCollection(createEmptyCollection(id, { name }));
|
collectionService.addCollection(createEmptyCollection(id, { name }));
|
||||||
track.$.navigationPanel.organize.createOrganizeItem({
|
track.$.navigationPanel.organize.createOrganizeItem({
|
||||||
type: 'collection',
|
type: 'collection',
|
||||||
});
|
|
||||||
workbenchService.workbench.openCollection(id);
|
|
||||||
explorerSection.setCollapsed(false);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
});
|
||||||
}, [
|
workbenchService.workbench.openCollection(id);
|
||||||
collectionService,
|
explorerSection.setCollapsed(false);
|
||||||
explorerSection,
|
},
|
||||||
openCreateCollectionModel,
|
[collectionService, explorerSection, workbenchService.workbench]
|
||||||
workbenchService.workbench,
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
@@ -65,7 +56,13 @@ export const ExplorerCollections = () => {
|
|||||||
<AddItemPlaceholder
|
<AddItemPlaceholder
|
||||||
data-testid="explorer-bar-add-collection-button"
|
data-testid="explorer-bar-add-collection-button"
|
||||||
label={t['com.affine.rootAppSidebar.collection.new']()}
|
label={t['com.affine.rootAppSidebar.collection.new']()}
|
||||||
onClick={handleCreateCollection}
|
onClick={() => setShowCreateCollectionModal(true)}
|
||||||
|
/>
|
||||||
|
<CollectionRenameDialog
|
||||||
|
title={t['com.affine.m.explorer.collection.new-dialog-title']()}
|
||||||
|
open={showCreateCollectionModal}
|
||||||
|
onOpenChange={setShowCreateCollectionModal}
|
||||||
|
onConfirm={handleCreateCollection}
|
||||||
/>
|
/>
|
||||||
</ExplorerTreeRoot>
|
</ExplorerTreeRoot>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import { OrganizeService } from '@affine/core/modules/organize';
|
|||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import track from '@affine/track';
|
import track from '@affine/track';
|
||||||
import { useLiveData, useServices } from '@toeverything/infra';
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
||||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||||
import { ExplorerFolderNode } from '../../nodes/folder';
|
import { ExplorerFolderNode } from '../../nodes/folder';
|
||||||
|
import { FolderCreateTip, FolderRenameDialog } from '../../nodes/folder/dialog';
|
||||||
|
|
||||||
export const ExplorerOrganize = () => {
|
export const ExplorerOrganize = () => {
|
||||||
const { organizeService, explorerService } = useServices({
|
const { organizeService, explorerService } = useServices({
|
||||||
@@ -19,8 +20,7 @@ export const ExplorerOrganize = () => {
|
|||||||
ExplorerService,
|
ExplorerService,
|
||||||
});
|
});
|
||||||
const explorerSection = explorerService.sections.organize;
|
const explorerSection = explorerService.sections.organize;
|
||||||
const collapsed = useLiveData(explorerSection.collapsed$);
|
const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false);
|
||||||
const [newFolderId, setNewFolderId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
@@ -30,20 +30,18 @@ export const ExplorerOrganize = () => {
|
|||||||
const folders = useLiveData(rootFolder.sortedChildren$);
|
const folders = useLiveData(rootFolder.sortedChildren$);
|
||||||
const isLoading = useLiveData(folderTree.isLoading$);
|
const isLoading = useLiveData(folderTree.isLoading$);
|
||||||
|
|
||||||
const handleCreateFolder = useCallback(() => {
|
const handleCreateFolder = useCallback(
|
||||||
const newFolderId = rootFolder.createFolder(
|
(name: string) => {
|
||||||
'New Folder',
|
const newFolderId = rootFolder.createFolder(
|
||||||
rootFolder.indexAt('before')
|
name,
|
||||||
);
|
rootFolder.indexAt('before')
|
||||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
|
);
|
||||||
setNewFolderId(newFolderId);
|
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
|
||||||
explorerSection.setCollapsed(false);
|
explorerSection.setCollapsed(false);
|
||||||
return newFolderId;
|
return newFolderId;
|
||||||
}, [explorerSection, rootFolder]);
|
},
|
||||||
|
[explorerSection, rootFolder]
|
||||||
useEffect(() => {
|
);
|
||||||
if (collapsed) setNewFolderId(null); // reset new folder id to clear the renaming state
|
|
||||||
}, [collapsed]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
@@ -53,18 +51,20 @@ export const ExplorerOrganize = () => {
|
|||||||
{/* TODO(@CatsJuice): Organize loading UI */}
|
{/* TODO(@CatsJuice): Organize loading UI */}
|
||||||
<ExplorerTreeRoot placeholder={isLoading ? <Skeleton /> : null}>
|
<ExplorerTreeRoot placeholder={isLoading ? <Skeleton /> : null}>
|
||||||
{folders.map(child => (
|
{folders.map(child => (
|
||||||
<ExplorerFolderNode
|
<ExplorerFolderNode key={child.id} nodeId={child.id as string} />
|
||||||
key={child.id}
|
|
||||||
nodeId={child.id as string}
|
|
||||||
defaultRenaming={child.id === newFolderId}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
<AddItemPlaceholder
|
<AddItemPlaceholder
|
||||||
data-testid="explorer-bar-add-organize-button"
|
data-testid="explorer-bar-add-organize-button"
|
||||||
label={t['com.affine.rootAppSidebar.organize.add-folder']()}
|
label={t['com.affine.rootAppSidebar.organize.add-folder']()}
|
||||||
onClick={handleCreateFolder}
|
onClick={() => setOpenNewFolderDialog(true)}
|
||||||
/>
|
/>
|
||||||
</ExplorerTreeRoot>
|
</ExplorerTreeRoot>
|
||||||
|
<FolderRenameDialog
|
||||||
|
open={openNewFolderDialog}
|
||||||
|
onConfirm={handleCreateFolder}
|
||||||
|
onOpenChange={setOpenNewFolderDialog}
|
||||||
|
descRenderer={FolderCreateTip}
|
||||||
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { ExplorerService } from '@affine/core/modules/explorer';
|
import { ExplorerService } from '@affine/core/modules/explorer';
|
||||||
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
|
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
|
||||||
import type { Tag } from '@affine/core/modules/tag';
|
|
||||||
import { TagService } from '@affine/core/modules/tag';
|
import { TagService } from '@affine/core/modules/tag';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { track } from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import { useLiveData, useServices } from '@toeverything/infra';
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
||||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||||
import { ExplorerTagNode } from '../../nodes/tag';
|
import { ExplorerTagNode } from '../../nodes/tag';
|
||||||
|
import { TagRenameDialog } from '../../nodes/tag/dialog';
|
||||||
|
|
||||||
|
export const TagDesc = ({ input }: { input: string }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return input
|
||||||
|
? t['com.affine.m.explorer.tag.new-tip-not-empty']({ value: input })
|
||||||
|
: t['com.affine.m.explorer.tag.new-tip-empty']();
|
||||||
|
};
|
||||||
|
|
||||||
export const ExplorerTags = () => {
|
export const ExplorerTags = () => {
|
||||||
const { tagService, explorerService } = useServices({
|
const { tagService, explorerService } = useServices({
|
||||||
@@ -17,25 +25,20 @@ export const ExplorerTags = () => {
|
|||||||
ExplorerService,
|
ExplorerService,
|
||||||
});
|
});
|
||||||
const explorerSection = explorerService.sections.tags;
|
const explorerSection = explorerService.sections.tags;
|
||||||
const collapsed = useLiveData(explorerSection.collapsed$);
|
|
||||||
const [createdTag, setCreatedTag] = useState<Tag | null>(null);
|
|
||||||
const tags = useLiveData(tagService.tagList.tags$);
|
const tags = useLiveData(tagService.tagList.tags$);
|
||||||
|
const [showNewTagDialog, setShowNewTagDialog] = useState(false);
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
const handleCreateNewFavoriteDoc = useCallback(() => {
|
const handleNewTag = useCallback(
|
||||||
const newTags = tagService.tagList.createTag(
|
(name: string, color: string) => {
|
||||||
t['com.affine.rootAppSidebar.tags.new-tag'](),
|
setShowNewTagDialog(false);
|
||||||
tagService.randomTagColor()
|
tagService.tagList.createTag(name, color);
|
||||||
);
|
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
|
||||||
setCreatedTag(newTags);
|
explorerSection.setCollapsed(false);
|
||||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
|
},
|
||||||
explorerSection.setCollapsed(false);
|
[explorerSection, tagService]
|
||||||
}, [explorerSection, t, tagService]);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (collapsed) setCreatedTag(null); // reset created tag to clear the renaming state
|
|
||||||
}, [collapsed]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
@@ -44,19 +47,22 @@ export const ExplorerTags = () => {
|
|||||||
>
|
>
|
||||||
<ExplorerTreeRoot>
|
<ExplorerTreeRoot>
|
||||||
{tags.map(tag => (
|
{tags.map(tag => (
|
||||||
<ExplorerTagNode
|
<ExplorerTagNode key={tag.id} tagId={tag.id} />
|
||||||
key={tag.id}
|
|
||||||
tagId={tag.id}
|
|
||||||
defaultRenaming={createdTag?.id === tag.id}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
<AddItemPlaceholder
|
<AddItemPlaceholder
|
||||||
data-testid="explorer-bar-add-favorite-button"
|
data-testid="explorer-add-tag-button"
|
||||||
onClick={handleCreateNewFavoriteDoc}
|
onClick={() => setShowNewTagDialog(true)}
|
||||||
label={t[
|
label={t[
|
||||||
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
|
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
|
||||||
]()}
|
]()}
|
||||||
/>
|
/>
|
||||||
|
<TagRenameDialog
|
||||||
|
open={showNewTagDialog}
|
||||||
|
onOpenChange={setShowNewTagDialog}
|
||||||
|
onConfirm={handleNewTag}
|
||||||
|
enableAnimation
|
||||||
|
descRenderer={TagDesc}
|
||||||
|
/>
|
||||||
</ExplorerTreeRoot>
|
</ExplorerTreeRoot>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { MenuItem, MobileMenu } from '@affine/component';
|
import { MobileMenu } from '@affine/component';
|
||||||
import { RenameModal } from '@affine/component/rename-modal';
|
import type { BaseExplorerTreeNodeProps } from '@affine/core/modules/explorer';
|
||||||
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
|
|
||||||
import type {
|
|
||||||
BaseExplorerTreeNodeProps,
|
|
||||||
NodeOperation,
|
|
||||||
} from '@affine/core/modules/explorer';
|
|
||||||
import { ExplorerTreeContext } from '@affine/core/modules/explorer';
|
import { ExplorerTreeContext } from '@affine/core/modules/explorer';
|
||||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||||
import { extractEmojiIcon } from '@affine/core/utils';
|
import { extractEmojiIcon } from '@affine/core/utils';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
|
||||||
import { ArrowDownSmallIcon, EditIcon } from '@blocksuite/icons/rc';
|
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
@@ -33,9 +26,6 @@ export const ExplorerTreeNode = ({
|
|||||||
onClick,
|
onClick,
|
||||||
to,
|
to,
|
||||||
active,
|
active,
|
||||||
defaultRenaming,
|
|
||||||
renameable,
|
|
||||||
onRename,
|
|
||||||
disabled,
|
disabled,
|
||||||
collapsed,
|
collapsed,
|
||||||
extractEmojiAsIcon,
|
extractEmojiAsIcon,
|
||||||
@@ -47,18 +37,13 @@ export const ExplorerTreeNode = ({
|
|||||||
linkComponent: LinkComponent = WorkbenchLink,
|
linkComponent: LinkComponent = WorkbenchLink,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: ExplorerTreeNodeProps) => {
|
}: ExplorerTreeNodeProps) => {
|
||||||
const t = useI18n();
|
|
||||||
const context = useContext(ExplorerTreeContext);
|
const context = useContext(ExplorerTreeContext);
|
||||||
const level = context?.level ?? 0;
|
const level = context?.level ?? 0;
|
||||||
// If no onClick or to is provided, clicking on the node will toggle the collapse state
|
// If no onClick or to is provided, clicking on the node will toggle the collapse state
|
||||||
const clickForCollapse = !onClick && !to && !disabled;
|
const clickForCollapse = !onClick && !to && !disabled;
|
||||||
const [childCount, setChildCount] = useState(0);
|
const [childCount, setChildCount] = useState(0);
|
||||||
const [renaming, setRenaming] = useState(defaultRenaming);
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const appSidebarService = useService(AppSidebarService).sidebar;
|
|
||||||
const sidebarWidth = useLiveData(appSidebarService.width$);
|
|
||||||
|
|
||||||
const { emoji, name } = useMemo(() => {
|
const { emoji, name } = useMemo(() => {
|
||||||
if (!extractEmojiAsIcon || !rawName) {
|
if (!extractEmojiAsIcon || !rawName) {
|
||||||
return {
|
return {
|
||||||
@@ -73,39 +58,13 @@ export const ExplorerTreeNode = ({
|
|||||||
};
|
};
|
||||||
}, [extractEmojiAsIcon, rawName]);
|
}, [extractEmojiAsIcon, rawName]);
|
||||||
|
|
||||||
const presetOperations = useMemo(
|
|
||||||
() =>
|
|
||||||
(
|
|
||||||
[
|
|
||||||
renameable
|
|
||||||
? {
|
|
||||||
index: 0,
|
|
||||||
view: (
|
|
||||||
<MenuItem
|
|
||||||
key={'explorer-tree-rename'}
|
|
||||||
type={'default'}
|
|
||||||
prefixIcon={<EditIcon />}
|
|
||||||
onClick={() => setRenaming(true)}
|
|
||||||
>
|
|
||||||
{t['com.affine.menu.rename']()}
|
|
||||||
</MenuItem>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
] as (NodeOperation | null)[]
|
|
||||||
).filter((t): t is NodeOperation => t !== null),
|
|
||||||
[renameable, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { menuOperations } = useMemo(() => {
|
const { menuOperations } = useMemo(() => {
|
||||||
const sorted = [...presetOperations, ...operations].sort(
|
const sorted = [...operations].sort((a, b) => a.index - b.index);
|
||||||
(a, b) => a.index - b.index
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
menuOperations: sorted.filter(({ inline }) => !inline),
|
menuOperations: sorted.filter(({ inline }) => !inline),
|
||||||
inlineOperations: sorted.filter(({ inline }) => !!inline),
|
inlineOperations: sorted.filter(({ inline }) => !!inline),
|
||||||
};
|
};
|
||||||
}, [presetOperations, operations]);
|
}, [operations]);
|
||||||
|
|
||||||
const contextValue = useMemo(() => {
|
const contextValue = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -127,11 +86,6 @@ export const ExplorerTreeNode = ({
|
|||||||
[collapsed, setCollapsed]
|
[collapsed, setCollapsed]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
|
||||||
(newName: string) => onRename?.(newName),
|
|
||||||
[onRename]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (e.defaultPrevented) {
|
if (e.defaultPrevented) {
|
||||||
@@ -166,7 +120,7 @@ export const ExplorerTreeNode = ({
|
|||||||
<Fragment key={index}>{view}</Fragment>
|
<Fragment key={index}>{view}</Fragment>
|
||||||
))}
|
))}
|
||||||
>
|
>
|
||||||
<div className={styles.iconContainer}>
|
<div className={styles.iconContainer} data-testid="menu-trigger">
|
||||||
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
|
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
|
||||||
</div>
|
</div>
|
||||||
</MobileMenu>
|
</MobileMenu>
|
||||||
@@ -193,18 +147,6 @@ export const ExplorerTreeNode = ({
|
|||||||
data-collapsed={collapsed !== false}
|
data-collapsed={collapsed !== false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renameable && (
|
|
||||||
<RenameModal
|
|
||||||
open={!!renaming}
|
|
||||||
width={sidebarWidth - 32}
|
|
||||||
onOpenChange={setRenaming}
|
|
||||||
onRename={handleRename}
|
|
||||||
currentName={rawName ?? ''}
|
|
||||||
>
|
|
||||||
<div className={styles.itemRenameAnchor} />
|
|
||||||
</RenameModal>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './app-tabs';
|
export * from './app-tabs';
|
||||||
export * from './doc-card';
|
export * from './doc-card';
|
||||||
export * from './page-header';
|
export * from './page-header';
|
||||||
|
export * from './rename';
|
||||||
export * from './search-input';
|
export * from './search-input';
|
||||||
export * from './search-result';
|
export * from './search-result';
|
||||||
export * from './user-plan-tag';
|
export * from './user-plan-tag';
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const inputWrapper = style({
|
||||||
|
padding: '4px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
});
|
||||||
|
export const input = style({
|
||||||
|
width: '100%',
|
||||||
|
height: 42,
|
||||||
|
border: '1px solid ' + cssVarV2('input/border/active'),
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '0 4px',
|
||||||
|
});
|
||||||
|
export const desc = style({
|
||||||
|
padding: '11px 16px',
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '22px',
|
||||||
|
letterSpacing: -0.43,
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
});
|
||||||
|
export const doneWrapper = style({
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 16px',
|
||||||
|
});
|
||||||
|
export const done = style({
|
||||||
|
width: '100%',
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 400,
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Button, RowInput } from '@affine/component';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './content.css';
|
||||||
|
import type { RenameContentProps } from './type';
|
||||||
|
|
||||||
|
export const RenameContent = ({
|
||||||
|
initialName = '',
|
||||||
|
inputProps,
|
||||||
|
confirmButtonProps,
|
||||||
|
inputPrefixRenderer: InputPrefixRenderer,
|
||||||
|
inputBelowRenderer: InputBelowRenderer,
|
||||||
|
descRenderer: DescRenderer,
|
||||||
|
confirmText = 'Done',
|
||||||
|
onConfirm,
|
||||||
|
}: RenameContentProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const [value, setValue] = useState(initialName);
|
||||||
|
|
||||||
|
const { className: inputClassName, ...restInputProps } = inputProps ?? {};
|
||||||
|
const { className: confirmButtonClassName, ...restConfirmButtonProps } =
|
||||||
|
confirmButtonProps ?? {};
|
||||||
|
|
||||||
|
const handleDone = useCallback(() => {
|
||||||
|
onConfirm?.(value);
|
||||||
|
}, [onConfirm, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
{InputPrefixRenderer ? <InputPrefixRenderer input={value} /> : null}
|
||||||
|
<RowInput
|
||||||
|
autoFocus
|
||||||
|
className={clsx(styles.input, inputClassName)}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
data-testid="rename-input"
|
||||||
|
{...restInputProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{}
|
||||||
|
{InputBelowRenderer ? <InputBelowRenderer input={value} /> : null}
|
||||||
|
<div className={styles.desc}>
|
||||||
|
{DescRenderer ? (
|
||||||
|
<DescRenderer input={value} />
|
||||||
|
) : (
|
||||||
|
t['com.affine.m.rename-to']({ name: value })
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.doneWrapper}>
|
||||||
|
<Button
|
||||||
|
className={clsx(styles.done, confirmButtonClassName)}
|
||||||
|
onClick={handleDone}
|
||||||
|
disabled={!value}
|
||||||
|
variant="primary"
|
||||||
|
size="extraLarge"
|
||||||
|
data-testid="rename-confirm"
|
||||||
|
{...restConfirmButtonProps}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const header = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '10px 16px',
|
||||||
|
});
|
||||||
|
export const title = style({
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: '22px',
|
||||||
|
letterSpacing: -0.43,
|
||||||
|
color: cssVarV2('text/primary'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { IconButton, Modal } from '@affine/component';
|
||||||
|
import { CloseIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { RenameContent } from './content';
|
||||||
|
import * as styles from './dialog.css';
|
||||||
|
import type { RenameDialogProps } from './type';
|
||||||
|
|
||||||
|
export const RenameDialog = ({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: RenameDialogProps & {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const handleRename = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
onConfirm?.(value);
|
||||||
|
onOpenChange?.(false);
|
||||||
|
},
|
||||||
|
[onOpenChange, onConfirm]
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
onOpenChange?.(false);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
width="100%"
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
withoutCloseButton
|
||||||
|
contentOptions={{
|
||||||
|
style: { minHeight: 0, padding: '12px 0', borderRadius: 22 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
<IconButton size="24" icon={<CloseIcon />} onClick={close} />
|
||||||
|
</div>
|
||||||
|
{children ?? <RenameContent onConfirm={handleRename} {...props} />}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './content';
|
||||||
|
export * from './dialog';
|
||||||
|
export * from './sub-menu';
|
||||||
|
export * from './type';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { MobileMenuSub, useMobileMenuController } from '@affine/component';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { EditIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { RenameContent } from './content';
|
||||||
|
import type { RenameSubMenuProps } from './type';
|
||||||
|
|
||||||
|
export const RenameSubMenu = ({
|
||||||
|
initialName = '',
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
menuProps,
|
||||||
|
onConfirm,
|
||||||
|
...props
|
||||||
|
}: RenameSubMenuProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { close } = useMobileMenuController();
|
||||||
|
|
||||||
|
const handleRename = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
onConfirm?.(value);
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
[close, onConfirm]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { triggerOptions, ...otherMenuProps } = menuProps ?? {};
|
||||||
|
return (
|
||||||
|
<MobileMenuSub
|
||||||
|
triggerOptions={{
|
||||||
|
prefixIcon: icon ?? <EditIcon />,
|
||||||
|
suffixIcon: null,
|
||||||
|
...triggerOptions,
|
||||||
|
}}
|
||||||
|
items={
|
||||||
|
children ?? (
|
||||||
|
<RenameContent
|
||||||
|
initialName={initialName}
|
||||||
|
onConfirm={handleRename}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={title}
|
||||||
|
{...otherMenuProps}
|
||||||
|
>
|
||||||
|
{text ?? t['com.affine.m.explorer.folder.rename']()}
|
||||||
|
</MobileMenuSub>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type {
|
||||||
|
ButtonProps,
|
||||||
|
MenuSubProps,
|
||||||
|
RowInputProps,
|
||||||
|
} from '@affine/component';
|
||||||
|
import type { PropsWithChildren, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface RenameBaseProps {
|
||||||
|
initialName?: string;
|
||||||
|
onConfirm?: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameContentProps extends RenameBaseProps {
|
||||||
|
inputProps?: Omit<RowInputProps, 'value' | 'onChange'>;
|
||||||
|
confirmButtonProps?: Omit<ButtonProps, 'onClick' | 'children'>;
|
||||||
|
confirmText?: string;
|
||||||
|
inputPrefixRenderer?: (props: { input: string }) => ReactNode;
|
||||||
|
descRenderer?: (props: { input: string }) => ReactNode;
|
||||||
|
inputBelowRenderer?: (props: { input: string }) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameSubMenuProps
|
||||||
|
extends PropsWithChildren<RenameContentProps> {
|
||||||
|
/** Submenu's title */
|
||||||
|
title?: string;
|
||||||
|
/** MenuItem.icon */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** MenuItem.text */
|
||||||
|
text?: string;
|
||||||
|
menuProps?: Partial<MenuSubProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameDialogProps
|
||||||
|
extends PropsWithChildren<RenameContentProps> {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ const NameWorkspaceContent = ({
|
|||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
loading,
|
loading,
|
||||||
disabled: !workspaceName,
|
disabled: !workspaceName,
|
||||||
['data-testid' as string]: 'create-workspace-create-button',
|
'data-testid': 'create-workspace-create-button',
|
||||||
}}
|
}}
|
||||||
closeButtonOptions={{
|
closeButtonOptions={{
|
||||||
['data-testid' as string]: 'create-workspace-close-button',
|
['data-testid' as string]: 'create-workspace-close-button',
|
||||||
|
|||||||
@@ -61,12 +61,9 @@ export interface BaseExplorerTreeNodeProps {
|
|||||||
icon?: ExplorerTreeNodeIcon;
|
icon?: ExplorerTreeNodeIcon;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
defaultRenaming?: boolean;
|
|
||||||
extractEmojiAsIcon?: boolean;
|
extractEmojiAsIcon?: boolean;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
setCollapsed: (collapsed: boolean) => void;
|
setCollapsed: (collapsed: boolean) => void;
|
||||||
renameable?: boolean;
|
|
||||||
onRename?: (newName: string) => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
to?: To;
|
to?: To;
|
||||||
@@ -81,6 +78,10 @@ export interface BaseExplorerTreeNodeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {
|
interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {
|
||||||
|
renameable?: boolean;
|
||||||
|
onRename?: (newName: string) => void;
|
||||||
|
defaultRenaming?: boolean;
|
||||||
|
|
||||||
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||||
reorderable?: boolean;
|
reorderable?: boolean;
|
||||||
dndData?: AffineDNDData;
|
dndData?: AffineDNDData;
|
||||||
|
|||||||
@@ -18,5 +18,5 @@
|
|||||||
"sv-SE": 5,
|
"sv-SE": 5,
|
||||||
"ur": 3,
|
"ur": 3,
|
||||||
"zh-Hans": 98,
|
"zh-Hans": 98,
|
||||||
"zh-Hant": 96
|
"zh-Hant": 97
|
||||||
}
|
}
|
||||||
@@ -1352,5 +1352,22 @@
|
|||||||
"system": "System",
|
"system": "System",
|
||||||
"unnamed": "unnamed",
|
"unnamed": "unnamed",
|
||||||
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.",
|
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.",
|
||||||
"com.affine.workspace.properties": "Workspace properties"
|
"com.affine.workspace.properties": "Workspace properties",
|
||||||
|
"com.affine.m.rename-to": "Rename to \"{{name}}\"",
|
||||||
|
"com.affine.m.explorer.folder.rename": "Rename",
|
||||||
|
"com.affine.m.explorer.folder.new-dialog-title": "Create Folder",
|
||||||
|
"com.affine.m.explorer.folder.root": "Organize",
|
||||||
|
"com.affine.m.explorer.folder.new-tip-empty": "Create a folder in the {{parent}}.",
|
||||||
|
"com.affine.m.explorer.folder.new-tip-not-empty": "Create \"{{value}}\" in the {{parent}}.",
|
||||||
|
"com.affine.m.explorer.folder.rename-confirm": "Done",
|
||||||
|
"com.affine.m.explorer.tag.rename": "Rename",
|
||||||
|
"com.affine.m.explorer.tag.rename-menu-title": "Rename Tag",
|
||||||
|
"com.affine.m.explorer.tag.new-dialog-title": "Create Tag",
|
||||||
|
"com.affine.m.explorer.tag.rename-confirm": "Done",
|
||||||
|
"com.affine.m.explorer.tag.new-tip-empty": "Create a tag in this workspace.",
|
||||||
|
"com.affine.m.explorer.tag.new-tip-not-empty": "Create \"{{value}}\" tag in this workspace.",
|
||||||
|
"com.affine.m.explorer.collection.rename": "Rename",
|
||||||
|
"com.affine.m.explorer.collection.rename-menu-title": "Rename Collection",
|
||||||
|
"com.affine.m.explorer.collection.new-dialog-title": "Create Collection",
|
||||||
|
"com.affine.m.explorer.doc.rename": "Rename"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { test } from '@affine-test/kit/mobile';
|
||||||
|
import { expect, type Locator, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expandCollapsibleSection,
|
||||||
|
getAttrOfActiveElement,
|
||||||
|
openExplorerNodeMenu,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
const locateFolder = async (scope: Page | Locator, name: string) => {
|
||||||
|
return scope.locator(`[data-role="explorer-folder"][aria-label="${name}"]`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rename input is focused
|
||||||
|
*/
|
||||||
|
const isRenameInputFocused = async (page: Page) => {
|
||||||
|
const focusElTestid = await getAttrOfActiveElement(page);
|
||||||
|
expect(focusElTestid).toEqual('rename-input');
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRootFolder = async (page: Page, name: string) => {
|
||||||
|
const section = await expandCollapsibleSection(page, 'organize');
|
||||||
|
await section.getByTestId('explorer-bar-add-organize-button').tap();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await isRenameInputFocused(page);
|
||||||
|
await page.keyboard.type(name);
|
||||||
|
await dialog.getByTestId('rename-confirm').tap();
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
const node = await locateFolder(section, name);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSubFolder = async (page: Page, parent: Locator, name: string) => {
|
||||||
|
const menu = await openExplorerNodeMenu(page, parent);
|
||||||
|
await menu.getByTestId('create-subfolder').tap();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await isRenameInputFocused(page);
|
||||||
|
await page.keyboard.type(name);
|
||||||
|
await dialog.getByTestId('rename-confirm').tap();
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
const node = await locateFolder(parent, name);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('create a folder', async ({ page }) => {
|
||||||
|
const node = await createRootFolder(page, 'Test Folder');
|
||||||
|
await expect(node).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create a sub folder', async ({ page }) => {
|
||||||
|
const parent = await createRootFolder(page, 'Parent Folder');
|
||||||
|
await expect(parent).toBeVisible();
|
||||||
|
await parent.tap();
|
||||||
|
const child = await createSubFolder(page, parent, 'Child Folder');
|
||||||
|
await expect(child).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create a folder and rename it', async ({ page }) => {
|
||||||
|
const originalName = 'Test Folder';
|
||||||
|
const appendedName = ' Renamed';
|
||||||
|
|
||||||
|
const folder = await createRootFolder(page, originalName);
|
||||||
|
const menu = await openExplorerNodeMenu(page, folder);
|
||||||
|
await menu.getByTestId('rename-folder').tap();
|
||||||
|
await isRenameInputFocused(page);
|
||||||
|
await page.keyboard.type(appendedName);
|
||||||
|
await menu.getByTestId('rename-confirm').tap();
|
||||||
|
|
||||||
|
const renamedFolder = await locateFolder(page, originalName + appendedName);
|
||||||
|
await expect(folder).not.toBeVisible();
|
||||||
|
await expect(renamedFolder).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { test } from '@affine-test/kit/mobile';
|
||||||
|
import { expect, type Locator, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
expandCollapsibleSection,
|
||||||
|
getAttrOfActiveElement,
|
||||||
|
openExplorerNodeMenu,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
async function locateTag(scope: Page | Locator, name: string) {
|
||||||
|
return scope.locator(`[data-role="explorer-tag"][aria-label="${name}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExplorerTagColor(tagNode: Locator) {
|
||||||
|
const icon = tagNode.getByTestId('explorer-tag-icon-dot');
|
||||||
|
await expect(icon).toBeVisible();
|
||||||
|
const color = await icon.evaluate(el => el.style.backgroundColor);
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeTagColor(scope: Locator, color: string) {
|
||||||
|
const trigger = scope.getByTestId('tag-color-picker-trigger');
|
||||||
|
const select = scope.getByTestId('tag-color-picker-select');
|
||||||
|
await trigger.tap();
|
||||||
|
await expect(select).toBeVisible();
|
||||||
|
const colorDot = select.locator(`[data-color="${color}"]`);
|
||||||
|
await colorDot.tap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRootTag(
|
||||||
|
page: Page,
|
||||||
|
name: string,
|
||||||
|
color = 'var(--affine-palette-line-red)'
|
||||||
|
) {
|
||||||
|
const section = await expandCollapsibleSection(page, 'tags');
|
||||||
|
await section.getByTestId('explorer-add-tag-button').tap();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
// input name
|
||||||
|
const focusedTestid = await getAttrOfActiveElement(page);
|
||||||
|
expect(focusedTestid).toEqual('rename-input');
|
||||||
|
await page.keyboard.type(name);
|
||||||
|
// set color
|
||||||
|
await changeTagColor(dialog, color);
|
||||||
|
// confirm
|
||||||
|
await dialog.getByTestId('rename-confirm').tap();
|
||||||
|
const tag = await locateTag(section, name);
|
||||||
|
await expect(tag).toBeVisible();
|
||||||
|
// check tag color
|
||||||
|
const fill = await getExplorerTagColor(tag);
|
||||||
|
expect(fill).toEqual(color);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('create a tag from explorer', async ({ page }) => {
|
||||||
|
await createRootTag(page, 'Test Tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rename a tag from explorer', async ({ page }) => {
|
||||||
|
const originalName = 'Test Tag';
|
||||||
|
const appendedName = ' Renamed';
|
||||||
|
|
||||||
|
const tag = await createRootTag(page, originalName);
|
||||||
|
const menu = await openExplorerNodeMenu(page, tag);
|
||||||
|
await menu.getByTestId('rename-tag').tap();
|
||||||
|
const focusedTestid = await getAttrOfActiveElement(page);
|
||||||
|
expect(focusedTestid).toEqual('rename-input');
|
||||||
|
await page.keyboard.type(appendedName);
|
||||||
|
await menu.getByTestId('rename-confirm').tap();
|
||||||
|
await expect(tag).not.toBeVisible();
|
||||||
|
const renamedTag = await locateTag(page, originalName + appendedName);
|
||||||
|
await expect(renamedTag).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('change tag color from explorer', async ({ page }) => {
|
||||||
|
const newColor = 'var(--affine-palette-line-green)';
|
||||||
|
const tagName = 'Test Tag';
|
||||||
|
const tag = await createRootTag(page, tagName);
|
||||||
|
const menu = await openExplorerNodeMenu(page, tag);
|
||||||
|
await menu.getByTestId('rename-tag').tap();
|
||||||
|
await changeTagColor(menu, newColor);
|
||||||
|
await menu.getByTestId('rename-confirm').tap();
|
||||||
|
|
||||||
|
const updatedTag = await locateTag(page, tagName);
|
||||||
|
const fill = await getExplorerTagColor(updatedTag);
|
||||||
|
expect(fill).toEqual(newColor);
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable unicorn/prefer-dom-node-dataset */
|
/* eslint-disable unicorn/prefer-dom-node-dataset */
|
||||||
import { expect, type Page } from '@playwright/test';
|
import { expect, type Locator, type Page } from '@playwright/test';
|
||||||
|
|
||||||
export async function expandCollapsibleSection(page: Page, name: string) {
|
export async function expandCollapsibleSection(page: Page, name: string) {
|
||||||
const divider = page.locator(`[data-collapsible]:has-text("${name}")`);
|
const divider = page.locator(`[data-collapsible]:has-text("${name}")`);
|
||||||
@@ -20,3 +20,24 @@ export async function expandCollapsibleSection(page: Page, name: string) {
|
|||||||
export async function pageBack(page: Page) {
|
export async function pageBack(page: Page) {
|
||||||
await page.getByTestId('page-header-back').tap();
|
await page.getByTestId('page-header-back').tap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAttrOfActiveElement(
|
||||||
|
page: Page,
|
||||||
|
attrName = 'data-testid'
|
||||||
|
) {
|
||||||
|
return await page.evaluate(name => {
|
||||||
|
const el = document.activeElement;
|
||||||
|
return el ? el.getAttribute(name) : '';
|
||||||
|
}, attrName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the context menu of an explorer node
|
||||||
|
* @returns Menu Locator
|
||||||
|
*/
|
||||||
|
export async function openExplorerNodeMenu(page: Page, node: Locator) {
|
||||||
|
await node.getByTestId('menu-trigger').tap();
|
||||||
|
const menu = page.getByRole('dialog');
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user