mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
@@ -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 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(() => {
|
||||
setCollapsed(false);
|
||||
}, []);
|
||||
@@ -105,7 +88,7 @@ export const ExplorerCollectionNode = ({
|
||||
return [...additionalOperations, ...collectionOperations];
|
||||
}
|
||||
return collectionOperations;
|
||||
}, [collectionOperations, additionalOperations]);
|
||||
}, [additionalOperations, collectionOperations]);
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
@@ -115,12 +98,10 @@ export const ExplorerCollectionNode = ({
|
||||
<ExplorerTreeNode
|
||||
icon={CollectionIcon}
|
||||
name={collection.name || t['Untitled']()}
|
||||
renameable
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
to={`/collection/${collection.id}`}
|
||||
active={active}
|
||||
onRename={handleRename}
|
||||
operations={finalOperations}
|
||||
data-testid={`explorer-collection-${collectionId}`}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
IconButton,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
notify,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { CollectionRenameSubMenu } from './dialog';
|
||||
|
||||
export const useExplorerCollectionNodeOperations = (
|
||||
collectionId: string,
|
||||
onOpenCollapsed: () => void,
|
||||
@@ -113,6 +116,24 @@ export const useExplorerCollectionNodeOperations = (
|
||||
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(
|
||||
() => ({
|
||||
favorite,
|
||||
@@ -122,6 +143,7 @@ export const useExplorerCollectionNodeOperations = (
|
||||
handleOpenInSplitView,
|
||||
handleShowEdit,
|
||||
handleToggleFavoriteCollection,
|
||||
handleRename,
|
||||
}),
|
||||
[
|
||||
favorite,
|
||||
@@ -129,6 +151,7 @@ export const useExplorerCollectionNodeOperations = (
|
||||
handleDeleteCollection,
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleRename,
|
||||
handleShowEdit,
|
||||
handleToggleFavoriteCollection,
|
||||
]
|
||||
@@ -154,6 +177,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
||||
handleOpenInSplitView,
|
||||
handleShowEdit,
|
||||
handleToggleFavoriteCollection,
|
||||
handleRename,
|
||||
} = useExplorerCollectionNodeOperations(
|
||||
collectionId,
|
||||
onOpenCollapsed,
|
||||
@@ -177,6 +201,14 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 10,
|
||||
view: <CollectionRenameSubMenu onConfirm={handleRename} />,
|
||||
},
|
||||
{
|
||||
index: 11,
|
||||
view: <MenuSeparator />,
|
||||
},
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
@@ -256,6 +288,7 @@ export const useExplorerCollectionNodeOperationsMenu = (
|
||||
handleDeleteCollection,
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleRename,
|
||||
handleShowEdit,
|
||||
handleToggleFavoriteCollection,
|
||||
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 { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||
import { DocInfoService } from '@affine/core/modules/doc-info';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import type { NodeOperation } from '@affine/core/modules/explorer';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import {
|
||||
DocsService,
|
||||
FeatureFlagService,
|
||||
@@ -92,14 +90,6 @@ export const ExplorerDocNode = ({
|
||||
);
|
||||
}, [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 option = useMemo(
|
||||
() => ({
|
||||
@@ -126,7 +116,6 @@ export const ExplorerDocNode = ({
|
||||
<ExplorerTreeNode
|
||||
icon={Icon}
|
||||
name={t.t(docTitle)}
|
||||
renameable
|
||||
extractEmojiAsIcon={enableEmojiIcon}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
@@ -140,7 +129,6 @@ export const ExplorerDocNode = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
onRename={handleRename}
|
||||
operations={finalOperations}
|
||||
data-testid={`explorer-doc-${docId}`}
|
||||
>
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { DocRenameSubMenu } from './dialog';
|
||||
|
||||
export const useExplorerDocNodeOperations = (
|
||||
docId: string,
|
||||
options: {
|
||||
@@ -135,6 +137,14 @@ export const useExplorerDocNodeOperations = (
|
||||
});
|
||||
}, [docId, compatibleFavoriteItemsAdapter]);
|
||||
|
||||
const handleRename = useAsyncCallback(
|
||||
async (newName: string) => {
|
||||
await docsService.changeDocTitle(docId, newName);
|
||||
track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' });
|
||||
},
|
||||
[docId, docsService]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
favorite,
|
||||
@@ -145,6 +155,7 @@ export const useExplorerDocNodeOperations = (
|
||||
handleOpenInNewTab,
|
||||
handleMoveToTrash,
|
||||
handleOpenInfoModal,
|
||||
handleRename,
|
||||
}),
|
||||
[
|
||||
favorite,
|
||||
@@ -154,6 +165,7 @@ export const useExplorerDocNodeOperations = (
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleOpenInfoModal,
|
||||
handleRename,
|
||||
handleToggleFavoriteDoc,
|
||||
]
|
||||
);
|
||||
@@ -177,8 +189,12 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
handleOpenInNewTab,
|
||||
handleMoveToTrash,
|
||||
handleOpenInfoModal,
|
||||
handleRename,
|
||||
} = useExplorerDocNodeOperations(docId, options);
|
||||
|
||||
const docService = useService(DocsService);
|
||||
const docRecord = useLiveData(docService.list.doc$(docId));
|
||||
const title = useLiveData(docRecord?.title$);
|
||||
const enableMultiView = useLiveData(
|
||||
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,
|
||||
view: (
|
||||
@@ -289,8 +313,10 @@ export const useExplorerDocNodeOperationsMenu = (
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleOpenInfoModal,
|
||||
handleRename,
|
||||
handleToggleFavoriteDoc,
|
||||
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 { ExplorerDocNode } from '../doc';
|
||||
import { ExplorerTagNode } from '../tag';
|
||||
import { FolderCreateTip, FolderRenameSubMenu } from './dialog';
|
||||
import { FavoriteFolderOperation } from './operations';
|
||||
|
||||
export const ExplorerFolderNode = ({
|
||||
nodeId,
|
||||
defaultRenaming,
|
||||
operations,
|
||||
}: {
|
||||
defaultRenaming?: boolean;
|
||||
nodeId: string;
|
||||
operations?:
|
||||
| NodeOperation[]
|
||||
@@ -83,11 +82,7 @@ export const ExplorerFolderNode = ({
|
||||
|
||||
if (type === 'folder') {
|
||||
return (
|
||||
<ExplorerFolderNodeFolder
|
||||
node={node}
|
||||
defaultRenaming={defaultRenaming}
|
||||
operations={additionalOperations}
|
||||
/>
|
||||
<ExplorerFolderNodeFolder node={node} operations={additionalOperations} />
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
@@ -123,10 +118,8 @@ const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
|
||||
|
||||
const ExplorerFolderNodeFolder = ({
|
||||
node,
|
||||
defaultRenaming,
|
||||
operations: additionalOperations,
|
||||
}: {
|
||||
defaultRenaming?: boolean;
|
||||
node: FolderNode;
|
||||
operations?: NodeOperation[];
|
||||
}) => {
|
||||
@@ -144,7 +137,6 @@ const ExplorerFolderNodeFolder = ({
|
||||
featureFlagService.flags.enable_emoji_folder_icon.$
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [newFolderId, setNewFolderId] = useState<string | null>(null);
|
||||
|
||||
const { createPage } = usePageHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
@@ -182,15 +174,14 @@ const ExplorerFolderNodeFolder = ({
|
||||
setCollapsed(false);
|
||||
}, [createPage, node]);
|
||||
|
||||
const handleCreateSubfolder = useCallback(() => {
|
||||
const newFolderId = node.createFolder(
|
||||
t['com.affine.rootAppSidebar.organize.new-folders'](),
|
||||
node.indexAt('before')
|
||||
);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
|
||||
setCollapsed(false);
|
||||
setNewFolderId(newFolderId);
|
||||
}, [node, t]);
|
||||
const handleCreateSubfolder = useCallback(
|
||||
(name: string) => {
|
||||
node.createFolder(name, node.indexAt('before'));
|
||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
|
||||
setCollapsed(false);
|
||||
},
|
||||
[node]
|
||||
);
|
||||
|
||||
const handleAddToFolder = useCallback(
|
||||
(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(() => {
|
||||
return [
|
||||
{
|
||||
@@ -254,12 +252,39 @@ const ExplorerFolderNodeFolder = ({
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 98,
|
||||
view: (
|
||||
<FolderRenameSubMenu
|
||||
initialName={name}
|
||||
onConfirm={handleRename}
|
||||
menuProps={{
|
||||
triggerOptions: { 'data-testid': 'rename-folder' },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 99,
|
||||
view: <MenuSeparator />,
|
||||
},
|
||||
{
|
||||
index: 100,
|
||||
view: (
|
||||
<MenuItem prefixIcon={<FolderIcon />} onClick={handleCreateSubfolder}>
|
||||
{t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()}
|
||||
</MenuItem>
|
||||
<FolderRenameSubMenu
|
||||
text={t[
|
||||
'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,
|
||||
handleCreateSubfolder,
|
||||
handleDelete,
|
||||
handleNewDoc,
|
||||
node,
|
||||
handleRename,
|
||||
name,
|
||||
node.id,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -370,7 +398,6 @@ const ExplorerFolderNodeFolder = ({
|
||||
|
||||
const handleCollapsedChange = useCallback((collapsed: boolean) => {
|
||||
if (collapsed) {
|
||||
setNewFolderId(null); // reset new folder id to clear the renaming state
|
||||
setCollapsed(true);
|
||||
} else {
|
||||
setCollapsed(false);
|
||||
@@ -381,26 +408,25 @@ const ExplorerFolderNodeFolder = ({
|
||||
<ExplorerTreeNode
|
||||
icon={ExplorerFolderIcon}
|
||||
name={name}
|
||||
defaultRenaming={defaultRenaming}
|
||||
renameable
|
||||
extractEmojiAsIcon={enableEmojiIcon}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={handleCollapsedChange}
|
||||
onRename={handleRename}
|
||||
operations={finalOperations}
|
||||
data-testid={`explorer-folder-${node.id}`}
|
||||
aria-label={name}
|
||||
data-role="explorer-folder"
|
||||
>
|
||||
{children.map(child => (
|
||||
<ExplorerFolderNode
|
||||
key={child.id}
|
||||
nodeId={child.id as string}
|
||||
defaultRenaming={child.id === newFolderId}
|
||||
operations={childrenOperations}
|
||||
/>
|
||||
))}
|
||||
<AddItemPlaceholder
|
||||
label={t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
|
||||
onClick={() => handleAddToFolder('doc')}
|
||||
data-testid="new-folder-in-folder-button"
|
||||
/>
|
||||
</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 { TagService } from '@affine/core/modules/tag';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import {
|
||||
GlobalContextService,
|
||||
useLiveData,
|
||||
@@ -23,10 +22,8 @@ import * as styles from './styles.css';
|
||||
export const ExplorerTagNode = ({
|
||||
tagId,
|
||||
operations: additionalOperations,
|
||||
defaultRenaming,
|
||||
}: {
|
||||
tagId: string;
|
||||
defaultRenaming?: boolean;
|
||||
operations?: NodeOperation[];
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
@@ -47,6 +44,7 @@ export const ExplorerTagNode = ({
|
||||
return (
|
||||
<div className={clsx(styles.tagIconContainer, className)}>
|
||||
<div
|
||||
data-testid="explorer-tag-icon-dot"
|
||||
className={styles.tagIcon}
|
||||
style={{
|
||||
backgroundColor: tagColor,
|
||||
@@ -58,18 +56,6 @@ export const ExplorerTagNode = ({
|
||||
[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(
|
||||
() => ({
|
||||
openNodeCollapsed: () => setCollapsed(false),
|
||||
@@ -94,15 +80,14 @@ export const ExplorerTagNode = ({
|
||||
<ExplorerTreeNode
|
||||
icon={Icon}
|
||||
name={tagName || t['Untitled']()}
|
||||
renameable
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
to={`/tag/${tagId}`}
|
||||
active={active}
|
||||
defaultRenaming={defaultRenaming}
|
||||
onRename={handleRename}
|
||||
operations={finalOperations}
|
||||
data-testid={`explorer-tag-${tagId}`}
|
||||
aria-label={tagName}
|
||||
data-role="explorer-tag"
|
||||
>
|
||||
<ExplorerTagNodeDocs tag={tagRecord} onNewDoc={handleNewDoc} />
|
||||
</ExplorerTreeNode>
|
||||
|
||||
@@ -7,12 +7,7 @@ import { TagService } from '@affine/core/modules/tag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import {
|
||||
DeleteIcon,
|
||||
OpenInNewIcon,
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { DeleteIcon, PlusIcon, SplitViewIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
DocsService,
|
||||
FeatureFlagService,
|
||||
@@ -23,6 +18,8 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { TagRenameSubMenu } from './dialog';
|
||||
|
||||
export const useExplorerTagNodeOperations = (
|
||||
tagId: string,
|
||||
{
|
||||
@@ -86,6 +83,37 @@ export const useExplorerTagNodeOperations = (
|
||||
track.$.navigationPanel.organize.openInNewTab({ type: 'tag' });
|
||||
}, [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(
|
||||
() => ({
|
||||
favorite,
|
||||
@@ -94,13 +122,19 @@ export const useExplorerTagNodeOperations = (
|
||||
handleOpenInSplitView,
|
||||
handleToggleFavoriteTag,
|
||||
handleOpenInNewTab,
|
||||
handleRename,
|
||||
handleChangeColor,
|
||||
handleChangeNameOrColor,
|
||||
}),
|
||||
[
|
||||
favorite,
|
||||
handleChangeColor,
|
||||
handleChangeNameOrColor,
|
||||
handleMoveToTrash,
|
||||
handleNewDoc,
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleRename,
|
||||
handleToggleFavoriteTag,
|
||||
]
|
||||
);
|
||||
@@ -122,7 +156,7 @@ export const useExplorerTagNodeOperationsMenu = (
|
||||
handleMoveToTrash,
|
||||
handleOpenInSplitView,
|
||||
handleToggleFavoriteTag,
|
||||
handleOpenInNewTab,
|
||||
handleChangeNameOrColor,
|
||||
} = useExplorerTagNodeOperations(tagId, option);
|
||||
|
||||
return useMemo(
|
||||
@@ -131,21 +165,19 @@ export const useExplorerTagNodeOperationsMenu = (
|
||||
index: 0,
|
||||
inline: true,
|
||||
view: (
|
||||
<IconButton
|
||||
size="16"
|
||||
onClick={handleNewDoc}
|
||||
tooltip={t['com.affine.rootAppSidebar.explorer.tag-add-tooltip']()}
|
||||
>
|
||||
<IconButton size="16" onClick={handleNewDoc}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 50,
|
||||
index: 10,
|
||||
view: (
|
||||
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
||||
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||
</MenuItem>
|
||||
<TagRenameSubMenu
|
||||
onConfirm={handleChangeNameOrColor}
|
||||
tagId={tagId}
|
||||
menuProps={{ triggerOptions: { 'data-testid': 'rename-tag' } }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron && enableMultiView
|
||||
@@ -196,12 +228,13 @@ export const useExplorerTagNodeOperationsMenu = (
|
||||
[
|
||||
enableMultiView,
|
||||
favorite,
|
||||
handleChangeNameOrColor,
|
||||
handleMoveToTrash,
|
||||
handleNewDoc,
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleToggleFavoriteTag,
|
||||
t,
|
||||
tagId,
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEditCollectionName } from '@affine/core/components/page-list';
|
||||
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { ExplorerService } from '@affine/core/modules/explorer';
|
||||
@@ -8,11 +7,12 @@ import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
|
||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||
import { CollectionRenameDialog } from '../../nodes/collection/dialog';
|
||||
|
||||
export const ExplorerCollections = () => {
|
||||
const t = useI18n();
|
||||
@@ -23,31 +23,22 @@ export const ExplorerCollections = () => {
|
||||
});
|
||||
const explorerSection = explorerService.sections.collections;
|
||||
const collections = useLiveData(collectionService.collections$);
|
||||
const { open: openCreateCollectionModel } = useEditCollectionName({
|
||||
title: t['com.affine.editCollection.createCollection'](),
|
||||
showTips: true,
|
||||
});
|
||||
const [showCreateCollectionModal, setShowCreateCollectionModal] =
|
||||
useState(false);
|
||||
|
||||
const handleCreateCollection = useCallback(() => {
|
||||
openCreateCollectionModel('')
|
||||
.then(name => {
|
||||
const id = nanoid();
|
||||
collectionService.addCollection(createEmptyCollection(id, { name }));
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'collection',
|
||||
});
|
||||
workbenchService.workbench.openCollection(id);
|
||||
explorerSection.setCollapsed(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
const handleCreateCollection = useCallback(
|
||||
(name: string) => {
|
||||
setShowCreateCollectionModal(false);
|
||||
const id = nanoid();
|
||||
collectionService.addCollection(createEmptyCollection(id, { name }));
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'collection',
|
||||
});
|
||||
}, [
|
||||
collectionService,
|
||||
explorerSection,
|
||||
openCreateCollectionModel,
|
||||
workbenchService.workbench,
|
||||
]);
|
||||
workbenchService.workbench.openCollection(id);
|
||||
explorerSection.setCollapsed(false);
|
||||
},
|
||||
[collectionService, explorerSection, workbenchService.workbench]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
@@ -65,7 +56,13 @@ export const ExplorerCollections = () => {
|
||||
<AddItemPlaceholder
|
||||
data-testid="explorer-bar-add-collection-button"
|
||||
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>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -7,11 +7,12 @@ import { OrganizeService } from '@affine/core/modules/organize';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
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 { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||
import { ExplorerFolderNode } from '../../nodes/folder';
|
||||
import { FolderCreateTip, FolderRenameDialog } from '../../nodes/folder/dialog';
|
||||
|
||||
export const ExplorerOrganize = () => {
|
||||
const { organizeService, explorerService } = useServices({
|
||||
@@ -19,8 +20,7 @@ export const ExplorerOrganize = () => {
|
||||
ExplorerService,
|
||||
});
|
||||
const explorerSection = explorerService.sections.organize;
|
||||
const collapsed = useLiveData(explorerSection.collapsed$);
|
||||
const [newFolderId, setNewFolderId] = useState<string | null>(null);
|
||||
const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
@@ -30,20 +30,18 @@ export const ExplorerOrganize = () => {
|
||||
const folders = useLiveData(rootFolder.sortedChildren$);
|
||||
const isLoading = useLiveData(folderTree.isLoading$);
|
||||
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
const newFolderId = rootFolder.createFolder(
|
||||
'New Folder',
|
||||
rootFolder.indexAt('before')
|
||||
);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
|
||||
setNewFolderId(newFolderId);
|
||||
explorerSection.setCollapsed(false);
|
||||
return newFolderId;
|
||||
}, [explorerSection, rootFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) setNewFolderId(null); // reset new folder id to clear the renaming state
|
||||
}, [collapsed]);
|
||||
const handleCreateFolder = useCallback(
|
||||
(name: string) => {
|
||||
const newFolderId = rootFolder.createFolder(
|
||||
name,
|
||||
rootFolder.indexAt('before')
|
||||
);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
|
||||
explorerSection.setCollapsed(false);
|
||||
return newFolderId;
|
||||
},
|
||||
[explorerSection, rootFolder]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
@@ -53,18 +51,20 @@ export const ExplorerOrganize = () => {
|
||||
{/* TODO(@CatsJuice): Organize loading UI */}
|
||||
<ExplorerTreeRoot placeholder={isLoading ? <Skeleton /> : null}>
|
||||
{folders.map(child => (
|
||||
<ExplorerFolderNode
|
||||
key={child.id}
|
||||
nodeId={child.id as string}
|
||||
defaultRenaming={child.id === newFolderId}
|
||||
/>
|
||||
<ExplorerFolderNode key={child.id} nodeId={child.id as string} />
|
||||
))}
|
||||
<AddItemPlaceholder
|
||||
data-testid="explorer-bar-add-organize-button"
|
||||
label={t['com.affine.rootAppSidebar.organize.add-folder']()}
|
||||
onClick={handleCreateFolder}
|
||||
onClick={() => setOpenNewFolderDialog(true)}
|
||||
/>
|
||||
</ExplorerTreeRoot>
|
||||
<FolderRenameDialog
|
||||
open={openNewFolderDialog}
|
||||
onConfirm={handleCreateFolder}
|
||||
onOpenChange={setOpenNewFolderDialog}
|
||||
descRenderer={FolderCreateTip}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { ExplorerService } from '@affine/core/modules/explorer';
|
||||
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 { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
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 { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||
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 = () => {
|
||||
const { tagService, explorerService } = useServices({
|
||||
@@ -17,25 +25,20 @@ export const ExplorerTags = () => {
|
||||
ExplorerService,
|
||||
});
|
||||
const explorerSection = explorerService.sections.tags;
|
||||
const collapsed = useLiveData(explorerSection.collapsed$);
|
||||
const [createdTag, setCreatedTag] = useState<Tag | null>(null);
|
||||
const tags = useLiveData(tagService.tagList.tags$);
|
||||
const [showNewTagDialog, setShowNewTagDialog] = useState(false);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const handleCreateNewFavoriteDoc = useCallback(() => {
|
||||
const newTags = tagService.tagList.createTag(
|
||||
t['com.affine.rootAppSidebar.tags.new-tag'](),
|
||||
tagService.randomTagColor()
|
||||
);
|
||||
setCreatedTag(newTags);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
|
||||
explorerSection.setCollapsed(false);
|
||||
}, [explorerSection, t, tagService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) setCreatedTag(null); // reset created tag to clear the renaming state
|
||||
}, [collapsed]);
|
||||
const handleNewTag = useCallback(
|
||||
(name: string, color: string) => {
|
||||
setShowNewTagDialog(false);
|
||||
tagService.tagList.createTag(name, color);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
|
||||
explorerSection.setCollapsed(false);
|
||||
},
|
||||
[explorerSection, tagService]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
@@ -44,19 +47,22 @@ export const ExplorerTags = () => {
|
||||
>
|
||||
<ExplorerTreeRoot>
|
||||
{tags.map(tag => (
|
||||
<ExplorerTagNode
|
||||
key={tag.id}
|
||||
tagId={tag.id}
|
||||
defaultRenaming={createdTag?.id === tag.id}
|
||||
/>
|
||||
<ExplorerTagNode key={tag.id} tagId={tag.id} />
|
||||
))}
|
||||
<AddItemPlaceholder
|
||||
data-testid="explorer-bar-add-favorite-button"
|
||||
onClick={handleCreateNewFavoriteDoc}
|
||||
data-testid="explorer-add-tag-button"
|
||||
onClick={() => setShowNewTagDialog(true)}
|
||||
label={t[
|
||||
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
|
||||
]()}
|
||||
/>
|
||||
<TagRenameDialog
|
||||
open={showNewTagDialog}
|
||||
onOpenChange={setShowNewTagDialog}
|
||||
onConfirm={handleNewTag}
|
||||
enableAnimation
|
||||
descRenderer={TagDesc}
|
||||
/>
|
||||
</ExplorerTreeRoot>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { MenuItem, MobileMenu } from '@affine/component';
|
||||
import { RenameModal } from '@affine/component/rename-modal';
|
||||
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
|
||||
import type {
|
||||
BaseExplorerTreeNodeProps,
|
||||
NodeOperation,
|
||||
} from '@affine/core/modules/explorer';
|
||||
import { MobileMenu } from '@affine/component';
|
||||
import type { BaseExplorerTreeNodeProps } from '@affine/core/modules/explorer';
|
||||
import { ExplorerTreeContext } from '@affine/core/modules/explorer';
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import { extractEmojiIcon } from '@affine/core/utils';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowDownSmallIcon, EditIcon } from '@blocksuite/icons/rc';
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import {
|
||||
Fragment,
|
||||
@@ -33,9 +26,6 @@ export const ExplorerTreeNode = ({
|
||||
onClick,
|
||||
to,
|
||||
active,
|
||||
defaultRenaming,
|
||||
renameable,
|
||||
onRename,
|
||||
disabled,
|
||||
collapsed,
|
||||
extractEmojiAsIcon,
|
||||
@@ -47,18 +37,13 @@ export const ExplorerTreeNode = ({
|
||||
linkComponent: LinkComponent = WorkbenchLink,
|
||||
...otherProps
|
||||
}: ExplorerTreeNodeProps) => {
|
||||
const t = useI18n();
|
||||
const context = useContext(ExplorerTreeContext);
|
||||
const level = context?.level ?? 0;
|
||||
// If no onClick or to is provided, clicking on the node will toggle the collapse state
|
||||
const clickForCollapse = !onClick && !to && !disabled;
|
||||
const [childCount, setChildCount] = useState(0);
|
||||
const [renaming, setRenaming] = useState(defaultRenaming);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const appSidebarService = useService(AppSidebarService).sidebar;
|
||||
const sidebarWidth = useLiveData(appSidebarService.width$);
|
||||
|
||||
const { emoji, name } = useMemo(() => {
|
||||
if (!extractEmojiAsIcon || !rawName) {
|
||||
return {
|
||||
@@ -73,39 +58,13 @@ export const ExplorerTreeNode = ({
|
||||
};
|
||||
}, [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 sorted = [...presetOperations, ...operations].sort(
|
||||
(a, b) => a.index - b.index
|
||||
);
|
||||
const sorted = [...operations].sort((a, b) => a.index - b.index);
|
||||
return {
|
||||
menuOperations: sorted.filter(({ inline }) => !inline),
|
||||
inlineOperations: sorted.filter(({ inline }) => !!inline),
|
||||
};
|
||||
}, [presetOperations, operations]);
|
||||
}, [operations]);
|
||||
|
||||
const contextValue = useMemo(() => {
|
||||
return {
|
||||
@@ -127,11 +86,6 @@ export const ExplorerTreeNode = ({
|
||||
[collapsed, setCollapsed]
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(newName: string) => onRename?.(newName),
|
||||
[onRename]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.defaultPrevented) {
|
||||
@@ -166,7 +120,7 @@ export const ExplorerTreeNode = ({
|
||||
<Fragment key={index}>{view}</Fragment>
|
||||
))}
|
||||
>
|
||||
<div className={styles.iconContainer}>
|
||||
<div className={styles.iconContainer} data-testid="menu-trigger">
|
||||
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
|
||||
</div>
|
||||
</MobileMenu>
|
||||
@@ -193,18 +147,6 @@ export const ExplorerTreeNode = ({
|
||||
data-collapsed={collapsed !== false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renameable && (
|
||||
<RenameModal
|
||||
open={!!renaming}
|
||||
width={sidebarWidth - 32}
|
||||
onOpenChange={setRenaming}
|
||||
onRename={handleRename}
|
||||
currentName={rawName ?? ''}
|
||||
>
|
||||
<div className={styles.itemRenameAnchor} />
|
||||
</RenameModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './app-tabs';
|
||||
export * from './doc-card';
|
||||
export * from './page-header';
|
||||
export * from './rename';
|
||||
export * from './search-input';
|
||||
export * from './search-result';
|
||||
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>
|
||||
);
|
||||
};
|
||||
38
packages/frontend/core/src/mobile/components/rename/type.ts
Normal file
38
packages/frontend/core/src/mobile/components/rename/type.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user