mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
refactor(core): rename explorer to navigation-panel (#11876)
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
# explorer
|
||||
|
||||
file manager in app left sidebar
|
||||
@@ -1,28 +0,0 @@
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { GlobalCache } from '../storage';
|
||||
import { WorkspaceScope } from '../workspace';
|
||||
import { ExplorerSection } from './entities/explore-section';
|
||||
import { ExplorerService } from './services/explorer';
|
||||
export { ExplorerService } from './services/explorer';
|
||||
export type { CollapsibleSectionName } from './types';
|
||||
export { CollapsibleSection } from './views/layouts/collapsible-section';
|
||||
export { ExplorerCollections } from './views/sections/collections';
|
||||
export { ExplorerFavorites } from './views/sections/favorites';
|
||||
export { ExplorerMigrationFavorites } from './views/sections/migration-favorites';
|
||||
export { ExplorerOrganize } from './views/sections/organize';
|
||||
// for mobile
|
||||
export { ExplorerTreeRoot } from './views/tree';
|
||||
export { ExplorerTreeContext } from './views/tree/context';
|
||||
export type {
|
||||
BaseExplorerTreeNodeProps,
|
||||
ExplorerTreeNodeIcon,
|
||||
} from './views/tree/node';
|
||||
export type { NodeOperation } from './views/tree/types';
|
||||
|
||||
export function configureExplorerModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(ExplorerService)
|
||||
.entity(ExplorerSection, [GlobalCache]);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({});
|
||||
export const content = style({
|
||||
paddingTop: 6,
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
selectors: {
|
||||
'&[data-dragged-over="true"]': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { CategoryDivider } from '@affine/core/modules/app-sidebar/views';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import { ExplorerService } from '../../services/explorer';
|
||||
import type { CollapsibleSectionName } from '../../types';
|
||||
import { content, header, root } from './collapsible-section.css';
|
||||
|
||||
interface CollapsibleSectionProps extends PropsWithChildren {
|
||||
name: CollapsibleSectionName;
|
||||
title: string;
|
||||
actions?: ReactNode;
|
||||
|
||||
className?: string;
|
||||
testId?: string;
|
||||
|
||||
headerRef?: RefObject<HTMLDivElement>;
|
||||
headerTestId?: string;
|
||||
headerClassName?: string;
|
||||
|
||||
contentClassName?: string;
|
||||
contentStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
export const CollapsibleSection = ({
|
||||
name,
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
|
||||
className,
|
||||
testId,
|
||||
|
||||
headerRef,
|
||||
headerTestId,
|
||||
headerClassName,
|
||||
|
||||
contentClassName,
|
||||
contentStyle,
|
||||
}: CollapsibleSectionProps) => {
|
||||
const section = useService(ExplorerService).sections[name];
|
||||
|
||||
const collapsed = useLiveData(section.collapsed$);
|
||||
|
||||
const setCollapsed = useCallback(
|
||||
(v: boolean) => {
|
||||
section.setCollapsed(v);
|
||||
},
|
||||
[section]
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
data-collapsed={collapsed}
|
||||
className={clsx(root, className)}
|
||||
open={!collapsed}
|
||||
data-testid={testId}
|
||||
>
|
||||
<CategoryDivider
|
||||
data-testid={headerTestId}
|
||||
label={title}
|
||||
setCollapsed={setCollapsed}
|
||||
collapsed={collapsed}
|
||||
ref={headerRef}
|
||||
className={clsx(header, headerClassName)}
|
||||
>
|
||||
{actions}
|
||||
</CategoryDivider>
|
||||
<Collapsible.Content
|
||||
data-testid="collapsible-section-content"
|
||||
className={clsx(content, contentClassName)}
|
||||
style={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { fallbackVar, style } from '@vanilla-extract/css';
|
||||
|
||||
import { levelIndent } from '../tree/node.css';
|
||||
|
||||
export const emptyChildren = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVarV2('text/disable'),
|
||||
textAlign: 'left',
|
||||
userSelect: 'none',
|
||||
lineHeight: '22px',
|
||||
padding: '4px 0px',
|
||||
marginTop: 2,
|
||||
// 48 = node.paddingLeft + node.collapsable.width + node.icon.width + node.icon.marginRight
|
||||
// = 4 + 16 + 20 + 8
|
||||
// to align with node's content
|
||||
paddingLeft: `calc(${fallbackVar(levelIndent, '20px')} + 48px)`,
|
||||
selectors: {
|
||||
'&[data-dragged-over="true"]': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, type HTMLAttributes, type Ref } from 'react';
|
||||
|
||||
import { emptyChildren } from './empty-node-children.css';
|
||||
|
||||
export const EmptyNodeChildren = forwardRef(function EmptyNodeChildren(
|
||||
{ children, className, ...attrs }: HTMLAttributes<HTMLDivElement>,
|
||||
ref: Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
<div className={clsx(emptyChildren, className)} ref={ref} {...attrs}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const content = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '12px 0px',
|
||||
borderRadius: 8,
|
||||
selectors: {
|
||||
// assume that the section can be dragged over
|
||||
'&[data-dragged-over="true"]': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const iconWrapper = style({
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVarV2('button/emptyIconBackground'),
|
||||
});
|
||||
export const icon = style({
|
||||
fontSize: 20,
|
||||
color: cssVarV2('icon/secondary'),
|
||||
});
|
||||
export const message = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
textAlign: 'center',
|
||||
color: cssVarV2('text/tertiary'),
|
||||
userSelect: 'none',
|
||||
fontWeight: 400,
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const newButton = style({
|
||||
marginTop: 8,
|
||||
padding: '4px 8px',
|
||||
height: '30px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Button } from '@affine/component';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type JSX,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
type SVGAttributes,
|
||||
type SVGProps,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './empty-section.css';
|
||||
|
||||
interface ExplorerEmptySectionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
icon:
|
||||
| ((props: SVGProps<SVGSVGElement>) => JSX.Element)
|
||||
| ReactElement<SVGAttributes<SVGElement>>;
|
||||
message: string;
|
||||
messageTestId?: string;
|
||||
actionText?: string;
|
||||
onActionClick?: () => void;
|
||||
}
|
||||
|
||||
export const ExplorerEmptySection = forwardRef(function ExplorerEmptySection(
|
||||
{
|
||||
icon: Icon,
|
||||
message,
|
||||
messageTestId,
|
||||
actionText,
|
||||
children,
|
||||
className,
|
||||
onActionClick,
|
||||
...attrs
|
||||
}: ExplorerEmptySectionProps,
|
||||
ref: Ref<HTMLDivElement>
|
||||
) {
|
||||
const icon =
|
||||
typeof Icon === 'function' ? (
|
||||
<Icon className={styles.icon} />
|
||||
) : (
|
||||
cloneElement(Icon, { className: styles.icon })
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.content, className)} ref={ref} {...attrs}>
|
||||
<div className={styles.iconWrapper}>{icon}</div>
|
||||
<div data-testid={messageTestId} className={styles.message}>
|
||||
{message}
|
||||
</div>
|
||||
{actionText ? (
|
||||
<Button className={styles.newButton} onClick={onActionClick}>
|
||||
{actionText}
|
||||
</Button>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { type DropTargetDropEvent, useDropTarget } from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { EmptyNodeChildren } from '../../layouts/empty-node-children';
|
||||
|
||||
export const Empty = ({
|
||||
onDrop,
|
||||
}: {
|
||||
onDrop: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
}) => {
|
||||
const { dropTargetRef } = useDropTarget(
|
||||
() => ({
|
||||
onDrop,
|
||||
}),
|
||||
[onDrop]
|
||||
);
|
||||
const t = useI18n();
|
||||
return (
|
||||
<EmptyNodeChildren ref={dropTargetRef}>
|
||||
{t['com.affine.collection.emptyCollection']()}
|
||||
</EmptyNodeChildren>
|
||||
);
|
||||
};
|
||||
@@ -1,324 +0,0 @@
|
||||
import {
|
||||
AnimatedCollectionsIcon,
|
||||
type DropTargetDropEvent,
|
||||
type DropTargetOptions,
|
||||
MenuItem,
|
||||
toast,
|
||||
} from '@affine/component';
|
||||
import { filterPage } from '@affine/core/components/page-list';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { ShareDocsListService } from '@affine/core/modules/share-doc';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { PublicDocMode } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import type { DocMeta } from '@blocksuite/affine/store';
|
||||
import { FilterMinusIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
LiveData,
|
||||
useLiveData,
|
||||
useService,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||
import type { ExplorerTreeNodeIcon } from '../../tree/node';
|
||||
import { ExplorerDocNode } from '../doc';
|
||||
import type { GenericExplorerNode } from '../types';
|
||||
import { Empty } from './empty';
|
||||
import { useExplorerCollectionNodeOperations } from './operations';
|
||||
|
||||
const CollectionIcon: ExplorerTreeNodeIcon = ({
|
||||
className,
|
||||
draggedOver,
|
||||
treeInstruction,
|
||||
}) => (
|
||||
<AnimatedCollectionsIcon
|
||||
className={className}
|
||||
closed={!!draggedOver && treeInstruction?.type === 'make-child'}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ExplorerCollectionNode = ({
|
||||
collectionId,
|
||||
onDrop,
|
||||
location,
|
||||
reorderable,
|
||||
operations: additionalOperations,
|
||||
canDrop,
|
||||
dropEffect,
|
||||
}: {
|
||||
collectionId: string;
|
||||
} & GenericExplorerNode) => {
|
||||
const t = useI18n();
|
||||
const { globalContextService, workspaceDialogService } = useServices({
|
||||
GlobalContextService,
|
||||
WorkspaceDialogService,
|
||||
});
|
||||
const active =
|
||||
useLiveData(globalContextService.globalContext.collectionId.$) ===
|
||||
collectionId;
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
const collectionService = useService(CollectionService);
|
||||
const collection = useLiveData(collectionService.collection$(collectionId));
|
||||
|
||||
const dndData = useMemo(() => {
|
||||
return {
|
||||
draggable: {
|
||||
entity: {
|
||||
type: 'collection',
|
||||
id: collectionId,
|
||||
},
|
||||
from: location,
|
||||
},
|
||||
dropTarget: {
|
||||
at: 'explorer:doc',
|
||||
},
|
||||
} satisfies AffineDNDData;
|
||||
}, [collectionId, location]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(name: string) => {
|
||||
if (collection && collection.name !== name) {
|
||||
collectionService.updateCollection(collectionId, () => ({
|
||||
...collection,
|
||||
name,
|
||||
}));
|
||||
|
||||
track.$.navigationPanel.organize.renameOrganizeItem({
|
||||
type: 'collection',
|
||||
});
|
||||
toast(t['com.affine.toastMessage.rename']());
|
||||
}
|
||||
},
|
||||
[collection, collectionId, collectionService, t]
|
||||
);
|
||||
|
||||
const handleAddDocToCollection = useCallback(
|
||||
(docId: string) => {
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
if (collection.allowList.includes(docId)) {
|
||||
toast(t['com.affine.collection.addPage.alreadyExists']());
|
||||
} else {
|
||||
collectionService.addPageToCollection(collection.id, docId);
|
||||
}
|
||||
},
|
||||
[collection, collectionService, t]
|
||||
);
|
||||
|
||||
const handleDropOnCollection = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (collection && data.treeInstruction?.type === 'make-child') {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
handleAddDocToCollection(data.source.data.entity.id);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'link',
|
||||
target: 'doc',
|
||||
control: 'drag',
|
||||
});
|
||||
track.$.navigationPanel.collections.drop({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
onDrop?.(data);
|
||||
}
|
||||
},
|
||||
[collection, onDrop, handleAddDocToCollection]
|
||||
);
|
||||
|
||||
const handleDropEffectOnCollection = useCallback<ExplorerTreeNodeDropEffect>(
|
||||
data => {
|
||||
if (collection && data.treeInstruction?.type === 'make-child') {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
return 'link';
|
||||
}
|
||||
} else {
|
||||
return dropEffect?.(data);
|
||||
}
|
||||
return;
|
||||
},
|
||||
[collection, dropEffect]
|
||||
);
|
||||
|
||||
const handleDropOnPlaceholder = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (collection && data.source.data.entity?.type === 'doc') {
|
||||
handleAddDocToCollection(data.source.data.entity.id);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'collection',
|
||||
control: 'drag',
|
||||
});
|
||||
}
|
||||
},
|
||||
[collection, handleAddDocToCollection]
|
||||
);
|
||||
|
||||
const handleOpenCollapsed = useCallback(() => {
|
||||
setCollapsed(false);
|
||||
}, []);
|
||||
|
||||
const handleEditCollection = useCallback(() => {
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
workspaceDialogService.open('collection-editor', {
|
||||
collectionId: collection.id,
|
||||
});
|
||||
}, [collection, workspaceDialogService]);
|
||||
|
||||
const collectionOperations = useExplorerCollectionNodeOperations(
|
||||
collectionId,
|
||||
handleOpenCollapsed,
|
||||
handleEditCollection
|
||||
);
|
||||
|
||||
const finalOperations = useMemo(() => {
|
||||
if (additionalOperations) {
|
||||
return [...additionalOperations, ...collectionOperations];
|
||||
}
|
||||
return collectionOperations;
|
||||
}, [collectionOperations, additionalOperations]);
|
||||
|
||||
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||
() => args => {
|
||||
const entityType = args.source.data.entity?.type;
|
||||
return args.treeInstruction?.type !== 'make-child'
|
||||
? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true)
|
||||
: entityType === 'doc';
|
||||
},
|
||||
[canDrop]
|
||||
);
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ExplorerTreeNode
|
||||
icon={CollectionIcon}
|
||||
name={collection.name || t['Untitled']()}
|
||||
dndData={dndData}
|
||||
onDrop={handleDropOnCollection}
|
||||
renameable
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
to={`/collection/${collection.id}`}
|
||||
active={active}
|
||||
canDrop={handleCanDrop}
|
||||
reorderable={reorderable}
|
||||
onRename={handleRename}
|
||||
childrenPlaceholder={<Empty onDrop={handleDropOnPlaceholder} />}
|
||||
operations={finalOperations}
|
||||
dropEffect={handleDropEffectOnCollection}
|
||||
data-testid={`explorer-collection-${collectionId}`}
|
||||
>
|
||||
<ExplorerCollectionNodeChildren collection={collection} />
|
||||
</ExplorerTreeNode>
|
||||
);
|
||||
};
|
||||
|
||||
const ExplorerCollectionNodeChildren = ({
|
||||
collection,
|
||||
}: {
|
||||
collection: Collection;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
docsService,
|
||||
compatibleFavoriteItemsAdapter,
|
||||
shareDocsListService,
|
||||
collectionService,
|
||||
} = useServices({
|
||||
DocsService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
ShareDocsListService,
|
||||
CollectionService,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// TODO(@eyhn): loading & error UI
|
||||
shareDocsListService.shareDocs?.revalidate();
|
||||
}, [shareDocsListService]);
|
||||
|
||||
const docMetas = useLiveData(
|
||||
useMemo(
|
||||
() =>
|
||||
LiveData.computed(get => {
|
||||
return get(docsService.list.docs$).map(
|
||||
doc => get(doc.meta$) as DocMeta
|
||||
);
|
||||
}),
|
||||
[docsService]
|
||||
)
|
||||
);
|
||||
const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$);
|
||||
const allowList = useMemo(
|
||||
() => new Set(collection.allowList),
|
||||
[collection.allowList]
|
||||
);
|
||||
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
|
||||
|
||||
const handleRemoveFromAllowList = useCallback(
|
||||
(id: string) => {
|
||||
track.$.navigationPanel.collections.removeOrganizeItem({ type: 'doc' });
|
||||
collectionService.deletePageFromCollection(collection.id, id);
|
||||
toast(t['com.affine.collection.removePage.success']());
|
||||
},
|
||||
[collection.id, collectionService, t]
|
||||
);
|
||||
|
||||
const filtered = docMetas.filter(meta => {
|
||||
if (meta.trash) return false;
|
||||
const publicMode = shareDocs?.find(d => d.id === meta.id)?.mode;
|
||||
const pageData = {
|
||||
meta: meta as DocMeta,
|
||||
publicMode:
|
||||
publicMode === PublicDocMode.Edgeless
|
||||
? ('edgeless' as const)
|
||||
: publicMode === PublicDocMode.Page
|
||||
? ('page' as const)
|
||||
: undefined,
|
||||
favorite: favourites.some(fav => fav.id === meta.id),
|
||||
};
|
||||
return filterPage(collection, pageData);
|
||||
});
|
||||
|
||||
return filtered.map(doc => (
|
||||
<ExplorerDocNode
|
||||
key={doc.id}
|
||||
docId={doc.id}
|
||||
reorderable={false}
|
||||
location={{
|
||||
at: 'explorer:collection:filtered-docs',
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
operations={
|
||||
allowList
|
||||
? [
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<FilterMinusIcon />}
|
||||
onClick={() => handleRemoveFromAllowList(doc.id)}
|
||||
>
|
||||
{t['Remove special filter']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
));
|
||||
};
|
||||
@@ -1,215 +0,0 @@
|
||||
import {
|
||||
IconButton,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
|
||||
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import {
|
||||
DeleteIcon,
|
||||
FilterIcon,
|
||||
OpenInNewIcon,
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { NodeOperation } from '../../tree/types';
|
||||
|
||||
export const useExplorerCollectionNodeOperations = (
|
||||
collectionId: string,
|
||||
onOpenCollapsed: () => void,
|
||||
onOpenEdit: () => void
|
||||
): NodeOperation[] => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
workbenchService,
|
||||
workspaceService,
|
||||
collectionService,
|
||||
compatibleFavoriteItemsAdapter,
|
||||
} = useServices({
|
||||
WorkbenchService,
|
||||
WorkspaceService,
|
||||
CollectionService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
});
|
||||
const deleteInfo = useDeleteCollectionInfo();
|
||||
|
||||
const { createPage } = usePageHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
|
||||
const favorite = useLiveData(
|
||||
useMemo(
|
||||
() =>
|
||||
compatibleFavoriteItemsAdapter.isFavorite$(collectionId, 'collection'),
|
||||
[collectionId, compatibleFavoriteItemsAdapter]
|
||||
)
|
||||
);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const createAndAddDocument = useCallback(() => {
|
||||
const newDoc = createPage();
|
||||
collectionService.addPageToCollection(collectionId, newDoc.id);
|
||||
track.$.navigationPanel.collections.createDoc();
|
||||
track.$.navigationPanel.collections.addDocToCollection({
|
||||
control: 'button',
|
||||
});
|
||||
onOpenCollapsed();
|
||||
}, [collectionId, collectionService, createPage, onOpenCollapsed]);
|
||||
|
||||
const handleToggleFavoriteCollection = useCallback(() => {
|
||||
compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection');
|
||||
track.$.navigationPanel.organize.toggleFavorite({
|
||||
type: 'collection',
|
||||
});
|
||||
}, [compatibleFavoriteItemsAdapter, collectionId]);
|
||||
|
||||
const handleAddDocToCollection = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.collection.add-doc.confirm.title'](),
|
||||
description: t['com.affine.collection.add-doc.confirm.description'](),
|
||||
cancelText: t['Cancel'](),
|
||||
confirmText: t['Confirm'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'primary',
|
||||
},
|
||||
onConfirm: createAndAddDocument,
|
||||
});
|
||||
}, [createAndAddDocument, openConfirmModal, t]);
|
||||
|
||||
const handleOpenInSplitView = useCallback(() => {
|
||||
workbenchService.workbench.openCollection(collectionId, { at: 'beside' });
|
||||
track.$.navigationPanel.organize.openInSplitView({
|
||||
type: 'collection',
|
||||
});
|
||||
}, [collectionId, workbenchService.workbench]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
workbenchService.workbench.openCollection(collectionId, { at: 'new-tab' });
|
||||
track.$.navigationPanel.organize.openInNewTab({ type: 'collection' });
|
||||
}, [collectionId, workbenchService.workbench]);
|
||||
|
||||
const handleDeleteCollection = useCallback(() => {
|
||||
collectionService.deleteCollection(deleteInfo, collectionId);
|
||||
track.$.navigationPanel.organize.deleteOrganizeItem({
|
||||
type: 'collection',
|
||||
});
|
||||
}, [collectionId, collectionService, deleteInfo]);
|
||||
|
||||
const handleShowEdit = useCallback(() => {
|
||||
onOpenEdit();
|
||||
}, [onOpenEdit]);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
index: 0,
|
||||
inline: true,
|
||||
view: (
|
||||
<IconButton
|
||||
size="16"
|
||||
data-testid="collection-add-doc-button"
|
||||
onClick={handleAddDocToCollection}
|
||||
tooltip={t[
|
||||
'com.affine.rootAppSidebar.explorer.collection-add-tooltip'
|
||||
]()}
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<MenuItem prefixIcon={<FilterIcon />} onClick={handleShowEdit}>
|
||||
{t['com.affine.collection.menu.edit']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<PlusIcon />}
|
||||
onClick={handleAddDocToCollection}
|
||||
>
|
||||
{t['New Page']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
|
||||
onClick={handleToggleFavoriteCollection}
|
||||
>
|
||||
{favorite
|
||||
? t['com.affine.favoritePageOperation.remove']()
|
||||
: t['com.affine.favoritePageOperation.add']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
||||
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<SplitViewIcon />}
|
||||
onClick={handleOpenInSplitView}
|
||||
>
|
||||
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
index: 9999,
|
||||
view: <MenuSeparator key="menu-separator" />,
|
||||
},
|
||||
{
|
||||
index: 10000,
|
||||
view: (
|
||||
<MenuItem
|
||||
type={'danger'}
|
||||
prefixIcon={<DeleteIcon />}
|
||||
data-testid="collection-delete-button"
|
||||
onClick={handleDeleteCollection}
|
||||
>
|
||||
{t['Delete']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
favorite,
|
||||
handleAddDocToCollection,
|
||||
handleDeleteCollection,
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleShowEdit,
|
||||
handleToggleFavoriteCollection,
|
||||
t,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { type DropTargetDropEvent, useDropTarget } from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { EmptyNodeChildren } from '../../layouts/empty-node-children';
|
||||
|
||||
export const Empty = ({
|
||||
onDrop,
|
||||
noAccessible = false,
|
||||
}: {
|
||||
onDrop: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
noAccessible?: boolean;
|
||||
}) => {
|
||||
const { dropTargetRef } = useDropTarget<AffineDNDData>(
|
||||
() => ({
|
||||
onDrop,
|
||||
}),
|
||||
[onDrop]
|
||||
);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<EmptyNodeChildren ref={dropTargetRef}>
|
||||
{noAccessible
|
||||
? t['com.affine.share-menu.option.permission.no-access']()
|
||||
: t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||
</EmptyNodeChildren>
|
||||
);
|
||||
};
|
||||
@@ -1,302 +0,0 @@
|
||||
import {
|
||||
type DropTargetDropEvent,
|
||||
type DropTargetOptions,
|
||||
Loading,
|
||||
toast,
|
||||
Tooltip,
|
||||
} from '@affine/component';
|
||||
import { Guard } from '@affine/core/components/guard';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import {
|
||||
LiveData,
|
||||
MANUALLY_STOP,
|
||||
useLiveData,
|
||||
useService,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { NEVER } from 'rxjs';
|
||||
|
||||
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||
import type { GenericExplorerNode } from '../types';
|
||||
import { Empty } from './empty';
|
||||
import { useExplorerDocNodeOperations } from './operations';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const ExplorerDocNode = ({
|
||||
docId,
|
||||
onDrop,
|
||||
location,
|
||||
reorderable,
|
||||
isLinked,
|
||||
canDrop,
|
||||
operations: additionalOperations,
|
||||
dropEffect,
|
||||
}: {
|
||||
docId: string;
|
||||
isLinked?: boolean;
|
||||
forwardKey?: string;
|
||||
} & GenericExplorerNode) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
docsSearchService,
|
||||
docsService,
|
||||
globalContextService,
|
||||
docDisplayMetaService,
|
||||
featureFlagService,
|
||||
guardService,
|
||||
} = useServices({
|
||||
DocsSearchService,
|
||||
DocsService,
|
||||
GlobalContextService,
|
||||
DocDisplayMetaService,
|
||||
FeatureFlagService,
|
||||
GuardService,
|
||||
});
|
||||
|
||||
const active =
|
||||
useLiveData(globalContextService.globalContext.docId.$) === docId;
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||
const DocIcon = useLiveData(
|
||||
docDisplayMetaService.icon$(docId, {
|
||||
reference: isLinked,
|
||||
})
|
||||
);
|
||||
const docTitle = useLiveData(docDisplayMetaService.title$(docId));
|
||||
const isInTrash = useLiveData(docRecord?.trash$);
|
||||
const enableEmojiIcon = useLiveData(
|
||||
featureFlagService.flags.enable_emoji_doc_icon.$
|
||||
);
|
||||
|
||||
const Icon = useCallback(
|
||||
({ className }: { className?: string }) => {
|
||||
return <DocIcon className={className} />;
|
||||
},
|
||||
[DocIcon]
|
||||
);
|
||||
|
||||
const children = useLiveData(
|
||||
useMemo(
|
||||
() =>
|
||||
LiveData.from(
|
||||
!collapsed ? docsSearchService.watchRefsFrom(docId) : NEVER,
|
||||
null
|
||||
),
|
||||
[docsSearchService, docId, collapsed]
|
||||
)
|
||||
);
|
||||
const searching = children === null;
|
||||
|
||||
const [referencesLoading, setReferencesLoading] = useState(true);
|
||||
useLayoutEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
docsSearchService.indexer
|
||||
.waitForDocCompletedWithPriority(docId, 100, abortController.signal)
|
||||
.then(() => {
|
||||
setReferencesLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
if (err !== MANUALLY_STOP) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
abortController.abort(MANUALLY_STOP);
|
||||
};
|
||||
}, [docId, docsSearchService]);
|
||||
|
||||
const dndData = useMemo(() => {
|
||||
return {
|
||||
draggable: {
|
||||
entity: {
|
||||
type: 'doc',
|
||||
id: docId,
|
||||
},
|
||||
from: location,
|
||||
},
|
||||
dropTarget: {
|
||||
at: 'explorer:doc',
|
||||
},
|
||||
} satisfies AffineDNDData;
|
||||
}, [docId, location]);
|
||||
|
||||
const handleRename = useAsyncCallback(
|
||||
async (newName: string) => {
|
||||
await docsService.changeDocTitle(docId, newName);
|
||||
track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' });
|
||||
},
|
||||
[docId, docsService]
|
||||
);
|
||||
|
||||
const handleDropOnDoc = useAsyncCallback(
|
||||
async (data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (data.treeInstruction?.type === 'make-child') {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
const canEdit = await guardService.can('Doc_Update', docId);
|
||||
if (!canEdit) {
|
||||
toast(t['com.affine.no-permission']());
|
||||
return;
|
||||
}
|
||||
await docsService.addLinkedDoc(docId, data.source.data.entity.id);
|
||||
track.$.navigationPanel.docs.linkDoc({
|
||||
control: 'drag',
|
||||
});
|
||||
track.$.navigationPanel.docs.drop({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
} else {
|
||||
toast(t['com.affine.rootAppSidebar.doc.link-doc-only']());
|
||||
}
|
||||
} else {
|
||||
onDrop?.(data);
|
||||
}
|
||||
},
|
||||
[docId, docsService, guardService, onDrop, t]
|
||||
);
|
||||
|
||||
const handleDropEffectOnDoc = useCallback<ExplorerTreeNodeDropEffect>(
|
||||
data => {
|
||||
if (data.treeInstruction?.type === 'make-child') {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
return 'link';
|
||||
}
|
||||
} else {
|
||||
return dropEffect?.(data);
|
||||
}
|
||||
return;
|
||||
},
|
||||
[dropEffect]
|
||||
);
|
||||
|
||||
const handleDropOnPlaceholder = useAsyncCallback(
|
||||
async (data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
const canEdit = await guardService.can('Doc_Update', docId);
|
||||
if (!canEdit) {
|
||||
toast(t['com.affine.no-permission']());
|
||||
return;
|
||||
}
|
||||
// TODO(eyhn): timeout&error handling
|
||||
await docsService.addLinkedDoc(docId, data.source.data.entity.id);
|
||||
track.$.navigationPanel.docs.linkDoc({
|
||||
control: 'drag',
|
||||
});
|
||||
track.$.navigationPanel.docs.drop({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
} else {
|
||||
toast(t['com.affine.rootAppSidebar.doc.link-doc-only']());
|
||||
}
|
||||
},
|
||||
[docId, docsService, guardService, t]
|
||||
);
|
||||
|
||||
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||
() => args => {
|
||||
const entityType = args.source.data.entity?.type;
|
||||
return args.treeInstruction?.type !== 'make-child'
|
||||
? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true)
|
||||
: entityType === 'doc';
|
||||
},
|
||||
[canDrop]
|
||||
);
|
||||
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
const operations = useExplorerDocNodeOperations(
|
||||
docId,
|
||||
useMemo(
|
||||
() => ({
|
||||
openInfoModal: () => workspaceDialogService.open('doc-info', { docId }),
|
||||
openNodeCollapsed: () => setCollapsed(false),
|
||||
}),
|
||||
[docId, workspaceDialogService]
|
||||
)
|
||||
);
|
||||
|
||||
const finalOperations = useMemo(() => {
|
||||
if (additionalOperations) {
|
||||
return [...operations, ...additionalOperations];
|
||||
}
|
||||
return operations;
|
||||
}, [additionalOperations, operations]);
|
||||
|
||||
if (isInTrash || !docRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ExplorerTreeNode
|
||||
icon={Icon}
|
||||
name={t.t(docTitle)}
|
||||
dndData={dndData}
|
||||
onDrop={handleDropOnDoc}
|
||||
renameable
|
||||
extractEmojiAsIcon={enableEmojiIcon}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
canDrop={handleCanDrop}
|
||||
to={`/${docId}`}
|
||||
active={active}
|
||||
postfix={
|
||||
referencesLoading &&
|
||||
!collapsed && (
|
||||
<Tooltip
|
||||
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
||||
>
|
||||
<div className={styles.loadingIcon}>
|
||||
<Loading />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
reorderable={reorderable}
|
||||
renameableGuard={{
|
||||
docId,
|
||||
action: 'Doc_Update',
|
||||
}}
|
||||
onRename={handleRename}
|
||||
childrenPlaceholder={
|
||||
searching ? null : (
|
||||
<Empty
|
||||
onDrop={handleDropOnPlaceholder}
|
||||
noAccessible={!!children && children.length > 0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
operations={finalOperations}
|
||||
dropEffect={handleDropEffectOnDoc}
|
||||
data-testid={`explorer-doc-${docId}`}
|
||||
>
|
||||
<Guard docId={docId} permission="Doc_Read">
|
||||
{canRead =>
|
||||
canRead
|
||||
? children?.map((child, index) => (
|
||||
<ExplorerDocNode
|
||||
key={`${child.docId}-${index}`}
|
||||
docId={child.docId}
|
||||
reorderable={false}
|
||||
location={{
|
||||
at: 'explorer:doc:linked-docs',
|
||||
docId,
|
||||
}}
|
||||
isLinked
|
||||
/>
|
||||
))
|
||||
: null
|
||||
}
|
||||
</Guard>
|
||||
</ExplorerTreeNode>
|
||||
);
|
||||
};
|
||||
@@ -1,271 +0,0 @@
|
||||
import {
|
||||
IconButton,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
toast,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
|
||||
import { Guard } from '@affine/core/components/guard';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { GuardService } from '@affine/core/modules/permissions';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import {
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
InformationIcon,
|
||||
LinkedPageIcon,
|
||||
OpenInNewIcon,
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import type { NodeOperation } from '../../tree/types';
|
||||
|
||||
export const useExplorerDocNodeOperations = (
|
||||
docId: string,
|
||||
options: {
|
||||
openInfoModal: () => void;
|
||||
openNodeCollapsed: () => void;
|
||||
}
|
||||
): NodeOperation[] => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
workbenchService,
|
||||
workspaceService,
|
||||
docsService,
|
||||
compatibleFavoriteItemsAdapter,
|
||||
guardService,
|
||||
} = useServices({
|
||||
DocsService,
|
||||
WorkbenchService,
|
||||
WorkspaceService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
GuardService,
|
||||
});
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const [addLinkedPageLoading, setAddLinkedPageLoading] = useState(false);
|
||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||
|
||||
const { createPage } = usePageHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
|
||||
const favorite = useLiveData(
|
||||
useMemo(() => {
|
||||
return compatibleFavoriteItemsAdapter.isFavorite$(docId, 'doc');
|
||||
}, [docId, compatibleFavoriteItemsAdapter])
|
||||
);
|
||||
|
||||
const { duplicate } = useBlockSuiteMetaHelper();
|
||||
const handleDuplicate = useCallback(() => {
|
||||
duplicate(docId, true);
|
||||
track.$.navigationPanel.docs.createDoc();
|
||||
}, [docId, duplicate]);
|
||||
const handleOpenInfoModal = useCallback(() => {
|
||||
track.$.docInfoPanel.$.open();
|
||||
options.openInfoModal();
|
||||
}, [options]);
|
||||
|
||||
const handleMoveToTrash = useCallback(() => {
|
||||
if (!docRecord) {
|
||||
return;
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t['com.affine.moveToTrash.title'](),
|
||||
description: t['com.affine.moveToTrash.confirmModal.description']({
|
||||
title: docRecord.title$.value,
|
||||
}),
|
||||
confirmText: t['com.affine.moveToTrash.confirmModal.confirm'](),
|
||||
cancelText: t['com.affine.moveToTrash.confirmModal.cancel'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'error',
|
||||
},
|
||||
onConfirm() {
|
||||
docRecord.moveToTrash();
|
||||
track.$.navigationPanel.docs.deleteDoc({
|
||||
control: 'button',
|
||||
});
|
||||
toast(t['com.affine.toastMessage.movedTrash']());
|
||||
},
|
||||
});
|
||||
}, [docRecord, openConfirmModal, t]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
workbenchService.workbench.openDoc(docId, {
|
||||
at: 'new-tab',
|
||||
});
|
||||
track.$.navigationPanel.organize.openInNewTab({
|
||||
type: 'doc',
|
||||
});
|
||||
}, [docId, workbenchService]);
|
||||
|
||||
const handleOpenInSplitView = useCallback(() => {
|
||||
workbenchService.workbench.openDoc(docId, {
|
||||
at: 'beside',
|
||||
});
|
||||
track.$.navigationPanel.organize.openInSplitView({
|
||||
type: 'doc',
|
||||
});
|
||||
}, [docId, workbenchService.workbench]);
|
||||
|
||||
const handleAddLinkedPage = useAsyncCallback(async () => {
|
||||
setAddLinkedPageLoading(true);
|
||||
try {
|
||||
const canEdit = await guardService.can('Doc_Update', docId);
|
||||
if (!canEdit) {
|
||||
toast(t['com.affine.no-permission']());
|
||||
return;
|
||||
}
|
||||
const newDoc = createPage();
|
||||
// TODO: handle timeout & error
|
||||
await docsService.addLinkedDoc(docId, newDoc.id);
|
||||
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
|
||||
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
|
||||
options.openNodeCollapsed();
|
||||
} finally {
|
||||
setAddLinkedPageLoading(false);
|
||||
}
|
||||
}, [createPage, guardService, docId, docsService, options, t]);
|
||||
|
||||
const handleToggleFavoriteDoc = useCallback(() => {
|
||||
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
|
||||
track.$.navigationPanel.organize.toggleFavorite({
|
||||
type: 'doc',
|
||||
});
|
||||
}, [docId, compatibleFavoriteItemsAdapter]);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
index: 0,
|
||||
inline: true,
|
||||
view: (
|
||||
<IconButton
|
||||
size="16"
|
||||
icon={<PlusIcon />}
|
||||
tooltip={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
|
||||
onClick={handleAddLinkedPage}
|
||||
loading={addLinkedPageLoading}
|
||||
disabled={addLinkedPageLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 50,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<InformationIcon />}
|
||||
onClick={handleOpenInfoModal}
|
||||
>
|
||||
{t['com.affine.page-properties.page-info.view']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<Guard docId={docId} permission="Doc_Update">
|
||||
{canEdit => (
|
||||
<MenuItem
|
||||
prefixIcon={<LinkedPageIcon />}
|
||||
onClick={handleAddLinkedPage}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{t['com.affine.page-operation.add-linked-page']()}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Guard>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<MenuItem prefixIcon={<DuplicateIcon />} onClick={handleDuplicate}>
|
||||
{t['com.affine.header.option.duplicate']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 99,
|
||||
view: (
|
||||
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
||||
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 100,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<SplitViewIcon />}
|
||||
onClick={handleOpenInSplitView}
|
||||
>
|
||||
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
index: 199,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
|
||||
onClick={handleToggleFavoriteDoc}
|
||||
>
|
||||
{favorite
|
||||
? t['com.affine.favoritePageOperation.remove']()
|
||||
: t['com.affine.favoritePageOperation.add']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 9999,
|
||||
view: <MenuSeparator key="menu-separator" />,
|
||||
},
|
||||
{
|
||||
index: 10000,
|
||||
view: (
|
||||
<Guard docId={docId} permission="Doc_Trash">
|
||||
{canMoveToTrash => (
|
||||
<MenuItem
|
||||
type={'danger'}
|
||||
prefixIcon={<DeleteIcon />}
|
||||
onClick={handleMoveToTrash}
|
||||
disabled={!canMoveToTrash}
|
||||
>
|
||||
{t['com.affine.moveToTrash.title']()}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Guard>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
addLinkedPageLoading,
|
||||
docId,
|
||||
favorite,
|
||||
handleAddLinkedPage,
|
||||
handleDuplicate,
|
||||
handleMoveToTrash,
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleOpenInfoModal,
|
||||
handleToggleFavoriteDoc,
|
||||
t,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const loadingIcon = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const draggedOverHighlight = style({
|
||||
selectors: {
|
||||
'&[data-dragged-over="true"]': {
|
||||
background: cssVar('--affine-hover-color'),
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
type DropTargetDropEvent,
|
||||
type DropTargetOptions,
|
||||
useDropTarget,
|
||||
} from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { EmptyNodeChildren } from '../../layouts/empty-node-children';
|
||||
import { draggedOverHighlight } from './empty.css';
|
||||
|
||||
export const FolderEmpty = ({
|
||||
canDrop,
|
||||
onDrop,
|
||||
}: {
|
||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||
}) => {
|
||||
const { dropTargetRef } = useDropTarget(
|
||||
() => ({
|
||||
onDrop,
|
||||
canDrop,
|
||||
}),
|
||||
[onDrop, canDrop]
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
return (
|
||||
<EmptyNodeChildren ref={dropTargetRef} className={draggedOverHighlight}>
|
||||
{t['com.affine.rootAppSidebar.organize.empty-folder']()}
|
||||
</EmptyNodeChildren>
|
||||
);
|
||||
};
|
||||
@@ -1,798 +0,0 @@
|
||||
import {
|
||||
AnimatedCollectionsIcon,
|
||||
AnimatedFolderIcon,
|
||||
type DropTargetDropEvent,
|
||||
type DropTargetOptions,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
MenuSub,
|
||||
notify,
|
||||
} from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import {
|
||||
type FolderNode,
|
||||
OrganizeService,
|
||||
} from '@affine/core/modules/organize';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { Unreachable } from '@affine/env/constant';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import {
|
||||
DeleteIcon,
|
||||
FolderIcon,
|
||||
PageIcon,
|
||||
PlusIcon,
|
||||
PlusThickIcon,
|
||||
RemoveFolderIcon,
|
||||
TagsIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { difference } from 'lodash-es';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||
import type { ExplorerTreeNodeIcon } from '../../tree/node';
|
||||
import type { NodeOperation } from '../../tree/types';
|
||||
import { ExplorerCollectionNode } from '../collection';
|
||||
import { ExplorerDocNode } from '../doc';
|
||||
import { ExplorerTagNode } from '../tag';
|
||||
import type { GenericExplorerNode } from '../types';
|
||||
import { FolderEmpty } from './empty';
|
||||
import { FavoriteFolderOperation } from './operations';
|
||||
|
||||
export const ExplorerFolderNode = ({
|
||||
nodeId,
|
||||
onDrop,
|
||||
defaultRenaming,
|
||||
operations,
|
||||
location,
|
||||
dropEffect,
|
||||
canDrop,
|
||||
reorderable,
|
||||
}: {
|
||||
defaultRenaming?: boolean;
|
||||
nodeId: string;
|
||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>, node: FolderNode) => void;
|
||||
operations?:
|
||||
| NodeOperation[]
|
||||
| ((type: string, node: FolderNode) => NodeOperation[]);
|
||||
} & Omit<GenericExplorerNode, 'operations'>) => {
|
||||
const { organizeService } = useServices({
|
||||
OrganizeService,
|
||||
});
|
||||
const node = useLiveData(organizeService.folderTree.folderNode$(nodeId));
|
||||
const type = useLiveData(node?.type$);
|
||||
const data = useLiveData(node?.data$);
|
||||
const handleDrop = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
onDrop?.(data, node);
|
||||
},
|
||||
[node, onDrop]
|
||||
);
|
||||
const additionalOperations = useMemo(() => {
|
||||
if (!type || !node) {
|
||||
return;
|
||||
}
|
||||
if (typeof operations === 'function') {
|
||||
return operations(type, node);
|
||||
}
|
||||
return operations;
|
||||
}, [node, operations, type]);
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'folder') {
|
||||
return (
|
||||
<ExplorerFolderNodeFolder
|
||||
node={node}
|
||||
onDrop={handleDrop}
|
||||
defaultRenaming={defaultRenaming}
|
||||
operations={additionalOperations}
|
||||
dropEffect={dropEffect}
|
||||
reorderable={reorderable}
|
||||
canDrop={canDrop}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'doc') {
|
||||
return (
|
||||
data && (
|
||||
<ExplorerDocNode
|
||||
docId={data}
|
||||
location={location}
|
||||
onDrop={handleDrop}
|
||||
reorderable={reorderable}
|
||||
canDrop={canDrop}
|
||||
dropEffect={dropEffect}
|
||||
operations={additionalOperations}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else if (type === 'collection') {
|
||||
return (
|
||||
data && (
|
||||
<ExplorerCollectionNode
|
||||
collectionId={data}
|
||||
location={location}
|
||||
onDrop={handleDrop}
|
||||
canDrop={canDrop}
|
||||
reorderable={reorderable}
|
||||
dropEffect={dropEffect}
|
||||
operations={additionalOperations}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else if (type === 'tag') {
|
||||
return (
|
||||
data && (
|
||||
<ExplorerTagNode
|
||||
tagId={data}
|
||||
location={location}
|
||||
onDrop={handleDrop}
|
||||
canDrop={canDrop}
|
||||
reorderable
|
||||
dropEffect={dropEffect}
|
||||
operations={additionalOperations}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
// Define outside the `ExplorerFolderNodeFolder` to avoid re-render(the close animation won't play)
|
||||
const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
|
||||
collapsed,
|
||||
className,
|
||||
draggedOver,
|
||||
treeInstruction,
|
||||
}) => (
|
||||
<AnimatedFolderIcon
|
||||
className={className}
|
||||
open={
|
||||
!collapsed || (!!draggedOver && treeInstruction?.type === 'make-child')
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const ExplorerFolderNodeFolder = ({
|
||||
node,
|
||||
onDrop,
|
||||
defaultRenaming,
|
||||
location,
|
||||
operations: additionalOperations,
|
||||
canDrop,
|
||||
dropEffect,
|
||||
reorderable,
|
||||
}: {
|
||||
defaultRenaming?: boolean;
|
||||
node: FolderNode;
|
||||
} & GenericExplorerNode) => {
|
||||
const t = useI18n();
|
||||
const { workspaceService, featureFlagService, workspaceDialogService } =
|
||||
useServices({
|
||||
WorkspaceService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
FeatureFlagService,
|
||||
WorkspaceDialogService,
|
||||
});
|
||||
const name = useLiveData(node.name$);
|
||||
const enableEmojiIcon = useLiveData(
|
||||
featureFlagService.flags.enable_emoji_folder_icon.$
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [newFolderId, setNewFolderId] = useState<string | null>(null);
|
||||
|
||||
const { createPage } = usePageHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
const handleDelete = useCallback(() => {
|
||||
node.delete();
|
||||
track.$.navigationPanel.organize.deleteOrganizeItem({
|
||||
type: 'folder',
|
||||
});
|
||||
notify.success({
|
||||
title: t['com.affine.rootAppSidebar.organize.delete.notify-title']({
|
||||
name,
|
||||
}),
|
||||
message: t['com.affine.rootAppSidebar.organize.delete.notify-message'](),
|
||||
});
|
||||
}, [name, node, t]);
|
||||
|
||||
const children = useLiveData(node.sortedChildren$);
|
||||
|
||||
const dndData = useMemo(() => {
|
||||
if (!node.id) {
|
||||
throw new Unreachable();
|
||||
}
|
||||
return {
|
||||
draggable: {
|
||||
entity: {
|
||||
type: 'folder',
|
||||
id: node.id,
|
||||
},
|
||||
from: location,
|
||||
},
|
||||
dropTarget: {
|
||||
at: 'explorer:organize:folder',
|
||||
},
|
||||
} satisfies AffineDNDData;
|
||||
}, [location, node.id]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(newName: string) => {
|
||||
node.rename(newName);
|
||||
},
|
||||
[node]
|
||||
);
|
||||
|
||||
const handleDropOnFolder = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (data.source.data.entity?.type) {
|
||||
track.$.navigationPanel.folders.drop({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
}
|
||||
if (data.treeInstruction?.type === 'make-child') {
|
||||
if (data.source.data.entity?.type === 'folder') {
|
||||
if (
|
||||
node.id === data.source.data.entity.id ||
|
||||
node.beChildOf(data.source.data.entity.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
|
||||
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
|
||||
} else if (
|
||||
data.source.data.entity?.type === 'collection' ||
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'tag'
|
||||
) {
|
||||
if (data.source.data.from?.at === 'explorer:organize:folder-node') {
|
||||
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
|
||||
track.$.navigationPanel.organize.moveOrganizeItem({
|
||||
type: 'link',
|
||||
target: data.source.data.entity?.type,
|
||||
});
|
||||
} else {
|
||||
node.createLink(
|
||||
data.source.data.entity?.type,
|
||||
data.source.data.entity.id,
|
||||
node.indexAt('before')
|
||||
);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'link',
|
||||
target: data.source.data.entity?.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onDrop?.(data);
|
||||
}
|
||||
},
|
||||
[node, onDrop]
|
||||
);
|
||||
|
||||
const handleDropEffect = useCallback<ExplorerTreeNodeDropEffect>(
|
||||
data => {
|
||||
if (data.treeInstruction?.type === 'make-child') {
|
||||
if (data.source.data.entity?.type === 'folder') {
|
||||
if (
|
||||
node.id === data.source.data.entity.id ||
|
||||
node.beChildOf(data.source.data.entity.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return 'move';
|
||||
} else if (
|
||||
data.source.data.from?.at === 'explorer:organize:folder-node'
|
||||
) {
|
||||
return 'move';
|
||||
} else if (
|
||||
data.source.data.entity?.type === 'collection' ||
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'tag'
|
||||
) {
|
||||
return 'link';
|
||||
}
|
||||
} else {
|
||||
return dropEffect?.(data);
|
||||
}
|
||||
return;
|
||||
},
|
||||
[dropEffect, node]
|
||||
);
|
||||
|
||||
const handleDropOnPlaceholder = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (data.source.data.entity?.type) {
|
||||
track.$.navigationPanel.folders.drop({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
}
|
||||
if (data.source.data.entity?.type === 'folder') {
|
||||
if (
|
||||
node.id === data.source.data.entity.id ||
|
||||
node.beChildOf(data.source.data.entity.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
node.moveHere(data.source.data.entity.id, node.indexAt('before'));
|
||||
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
|
||||
} else if (
|
||||
data.source.data.entity?.type === 'collection' ||
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'tag'
|
||||
) {
|
||||
if (data.source.data.from?.at === 'explorer:organize:folder-node') {
|
||||
node.moveHere(data.source.data.from.nodeId, node.indexAt('before'));
|
||||
track.$.navigationPanel.organize.moveOrganizeItem({
|
||||
type: data.source.data.entity?.type,
|
||||
});
|
||||
} else {
|
||||
node.createLink(
|
||||
data.source.data.entity?.type,
|
||||
data.source.data.entity.id,
|
||||
node.indexAt('before')
|
||||
);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'link',
|
||||
target: data.source.data.entity?.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[node]
|
||||
);
|
||||
|
||||
const handleDropOnChildren = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>, dropAtNode?: FolderNode) => {
|
||||
if (!dropAtNode || !dropAtNode.id) {
|
||||
return;
|
||||
}
|
||||
if (data.source.data.entity?.type) {
|
||||
track.$.navigationPanel.folders.drop({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.treeInstruction?.type === 'reorder-above' ||
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
const at =
|
||||
data.treeInstruction?.type === 'reorder-below' ? 'after' : 'before';
|
||||
if (data.source.data.entity?.type === 'folder') {
|
||||
if (
|
||||
node.id === data.source.data.entity.id ||
|
||||
node.beChildOf(data.source.data.entity.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
node.moveHere(
|
||||
data.source.data.entity.id,
|
||||
node.indexAt(at, dropAtNode.id)
|
||||
);
|
||||
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
|
||||
} else if (
|
||||
data.source.data.entity?.type === 'collection' ||
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'tag'
|
||||
) {
|
||||
if (data.source.data.from?.at === 'explorer:organize:folder-node') {
|
||||
node.moveHere(
|
||||
data.source.data.from.nodeId,
|
||||
node.indexAt(at, dropAtNode.id)
|
||||
);
|
||||
track.$.navigationPanel.organize.moveOrganizeItem({
|
||||
type: 'link',
|
||||
target: data.source.data.entity?.type,
|
||||
});
|
||||
} else {
|
||||
node.createLink(
|
||||
data.source.data.entity?.type,
|
||||
data.source.data.entity.id,
|
||||
node.indexAt(at, dropAtNode.id)
|
||||
);
|
||||
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'link',
|
||||
target: data.source.data.entity?.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (data.treeInstruction?.type === 'reparent') {
|
||||
const currentLevel = data.treeInstruction.currentLevel;
|
||||
const desiredLevel = data.treeInstruction.desiredLevel;
|
||||
if (currentLevel === desiredLevel + 1) {
|
||||
onDrop?.({
|
||||
...data,
|
||||
treeInstruction: {
|
||||
type: 'reorder-below',
|
||||
currentLevel,
|
||||
indentPerLevel: data.treeInstruction.indentPerLevel,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
onDrop?.({
|
||||
...data,
|
||||
treeInstruction: {
|
||||
...data.treeInstruction,
|
||||
currentLevel: currentLevel - 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[node, onDrop]
|
||||
);
|
||||
|
||||
const handleDropEffectOnChildren = useCallback<ExplorerTreeNodeDropEffect>(
|
||||
data => {
|
||||
if (
|
||||
data.treeInstruction?.type === 'reorder-above' ||
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
if (data.source.data.entity?.type === 'folder') {
|
||||
if (
|
||||
node.id === data.source.data.entity.id ||
|
||||
node.beChildOf(data.source.data.entity.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return 'move';
|
||||
} else if (
|
||||
data.source.data.from?.at === 'explorer:organize:folder-node'
|
||||
) {
|
||||
return 'move';
|
||||
} else if (
|
||||
data.source.data.entity?.type === 'collection' ||
|
||||
data.source.data.entity?.type === 'doc' ||
|
||||
data.source.data.entity?.type === 'tag'
|
||||
) {
|
||||
return 'link';
|
||||
}
|
||||
} else if (data.treeInstruction?.type === 'reparent') {
|
||||
const currentLevel = data.treeInstruction.currentLevel;
|
||||
const desiredLevel = data.treeInstruction.desiredLevel;
|
||||
if (currentLevel === desiredLevel + 1) {
|
||||
dropEffect?.({
|
||||
...data,
|
||||
treeInstruction: {
|
||||
type: 'reorder-below',
|
||||
currentLevel,
|
||||
indentPerLevel: data.treeInstruction.indentPerLevel,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
dropEffect?.({
|
||||
...data,
|
||||
treeInstruction: {
|
||||
...data.treeInstruction,
|
||||
currentLevel: currentLevel - 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
},
|
||||
[dropEffect, node]
|
||||
);
|
||||
|
||||
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||
() => args => {
|
||||
const entityType = args.source.data.entity?.type;
|
||||
if (args.treeInstruction && args.treeInstruction?.type !== 'make-child') {
|
||||
return (
|
||||
(typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true
|
||||
);
|
||||
}
|
||||
|
||||
if (args.source.data.entity?.type === 'folder') {
|
||||
if (
|
||||
node.id === args.source.data.entity.id ||
|
||||
node.beChildOf(args.source.data.entity.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} else if (
|
||||
args.source.data.from?.at === 'explorer:organize:folder-node'
|
||||
) {
|
||||
return true;
|
||||
} else if (
|
||||
entityType === 'collection' ||
|
||||
entityType === 'doc' ||
|
||||
entityType === 'tag'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[canDrop, node]
|
||||
);
|
||||
|
||||
const handleChildrenCanDrop = useMemo<
|
||||
DropTargetOptions<AffineDNDData>['canDrop']
|
||||
>(
|
||||
() => args => {
|
||||
const entityType = args.source.data.entity?.type;
|
||||
|
||||
if (args.source.data.entity?.type === 'folder') {
|
||||
if (
|
||||
node.id === args.source.data.entity.id ||
|
||||
node.beChildOf(args.source.data.entity.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} else if (
|
||||
args.source.data.from?.at === 'explorer:organize:folder-node'
|
||||
) {
|
||||
return true;
|
||||
} else if (
|
||||
entityType === 'collection' ||
|
||||
entityType === 'doc' ||
|
||||
entityType === 'tag'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[node]
|
||||
);
|
||||
|
||||
const handleNewDoc = useCallback(() => {
|
||||
const newDoc = createPage();
|
||||
node.createLink('doc', newDoc.id, node.indexAt('before'));
|
||||
track.$.navigationPanel.folders.createDoc();
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'link',
|
||||
target: 'doc',
|
||||
});
|
||||
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 handleAddToFolder = useCallback(
|
||||
(type: 'doc' | 'collection' | 'tag') => {
|
||||
const initialIds = children
|
||||
.filter(node => node.type$.value === type)
|
||||
.map(node => node.data$.value)
|
||||
.filter(Boolean) as string[];
|
||||
const selector =
|
||||
type === 'doc'
|
||||
? 'doc-selector'
|
||||
: type === 'collection'
|
||||
? 'collection-selector'
|
||||
: 'tag-selector';
|
||||
workspaceDialogService.open(
|
||||
selector,
|
||||
{
|
||||
init: initialIds,
|
||||
},
|
||||
selectedIds => {
|
||||
if (selectedIds === undefined) {
|
||||
return;
|
||||
}
|
||||
const newItemIds = difference(selectedIds, initialIds);
|
||||
const removedItemIds = difference(initialIds, selectedIds);
|
||||
const removedItems = children.filter(
|
||||
node =>
|
||||
!!node.data$.value && removedItemIds.includes(node.data$.value)
|
||||
);
|
||||
|
||||
newItemIds.forEach(id => {
|
||||
node.createLink(type, id, node.indexAt('after'));
|
||||
});
|
||||
removedItems.forEach(node => node.delete());
|
||||
const updated = newItemIds.length + removedItems.length;
|
||||
updated && setCollapsed(false);
|
||||
}
|
||||
);
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'link',
|
||||
target: type,
|
||||
});
|
||||
},
|
||||
[children, node, workspaceDialogService]
|
||||
);
|
||||
|
||||
const folderOperations = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
index: 0,
|
||||
inline: true,
|
||||
view: (
|
||||
<IconButton
|
||||
size="16"
|
||||
onClick={handleNewDoc}
|
||||
tooltip={t[
|
||||
'com.affine.rootAppSidebar.explorer.organize-add-tooltip'
|
||||
]()}
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 100,
|
||||
view: (
|
||||
<MenuItem prefixIcon={<FolderIcon />} onClick={handleCreateSubfolder}>
|
||||
{t['com.affine.rootAppSidebar.organize.folder.create-subfolder']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 101,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<PageIcon />}
|
||||
onClick={() => handleAddToFolder('doc')}
|
||||
>
|
||||
{t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 102,
|
||||
view: (
|
||||
<MenuSub
|
||||
triggerOptions={{
|
||||
prefixIcon: <PlusThickIcon />,
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => handleAddToFolder('tag')}
|
||||
prefixIcon={<TagsIcon />}
|
||||
>
|
||||
{t['com.affine.rootAppSidebar.organize.folder.add-tags']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => handleAddToFolder('collection')}
|
||||
prefixIcon={<AnimatedCollectionsIcon closed={false} />}
|
||||
>
|
||||
{t[
|
||||
'com.affine.rootAppSidebar.organize.folder.add-collections'
|
||||
]()}
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{t['com.affine.rootAppSidebar.organize.folder.add-others']()}
|
||||
</MenuSub>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
index: 200,
|
||||
view: node.id ? <FavoriteFolderOperation id={node.id} /> : null,
|
||||
},
|
||||
|
||||
{
|
||||
index: 9999,
|
||||
view: <MenuSeparator key="menu-separator" />,
|
||||
},
|
||||
{
|
||||
index: 10000,
|
||||
view: (
|
||||
<MenuItem
|
||||
type={'danger'}
|
||||
prefixIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t['com.affine.rootAppSidebar.organize.delete']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [
|
||||
handleAddToFolder,
|
||||
handleCreateSubfolder,
|
||||
handleDelete,
|
||||
handleNewDoc,
|
||||
node,
|
||||
t,
|
||||
]);
|
||||
|
||||
const finalOperations = useMemo(() => {
|
||||
if (additionalOperations) {
|
||||
return [...additionalOperations, ...folderOperations];
|
||||
}
|
||||
return folderOperations;
|
||||
}, [additionalOperations, folderOperations]);
|
||||
|
||||
const childrenOperations = useCallback(
|
||||
(type: string, node: FolderNode) => {
|
||||
if (type === 'doc' || type === 'collection' || type === 'tag') {
|
||||
return [
|
||||
{
|
||||
index: 999,
|
||||
view: (
|
||||
<MenuItem
|
||||
type={'danger'}
|
||||
prefixIcon={<RemoveFolderIcon />}
|
||||
data-event-props="$.navigationPanel.organize.deleteOrganizeItem"
|
||||
data-event-args-type={node.type$.value}
|
||||
onClick={() => node.delete()}
|
||||
>
|
||||
{t['com.affine.rootAppSidebar.organize.delete-from-folder']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
] satisfies NodeOperation[];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleCollapsedChange = useCallback((collapsed: boolean) => {
|
||||
if (collapsed) {
|
||||
setNewFolderId(null); // reset new folder id to clear the renaming state
|
||||
setCollapsed(true);
|
||||
} else {
|
||||
setCollapsed(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ExplorerTreeNode
|
||||
icon={ExplorerFolderIcon}
|
||||
name={name}
|
||||
dndData={dndData}
|
||||
onDrop={handleDropOnFolder}
|
||||
defaultRenaming={defaultRenaming}
|
||||
renameable
|
||||
extractEmojiAsIcon={enableEmojiIcon}
|
||||
reorderable={reorderable}
|
||||
collapsed={collapsed}
|
||||
setCollapsed={handleCollapsedChange}
|
||||
onRename={handleRename}
|
||||
operations={finalOperations}
|
||||
canDrop={handleCanDrop}
|
||||
childrenPlaceholder={
|
||||
<FolderEmpty canDrop={handleCanDrop} onDrop={handleDropOnPlaceholder} />
|
||||
}
|
||||
dropEffect={handleDropEffect}
|
||||
data-testid={`explorer-folder-${node.id}`}
|
||||
>
|
||||
{children.map(child => (
|
||||
<ExplorerFolderNode
|
||||
key={child.id}
|
||||
nodeId={child.id as string}
|
||||
defaultRenaming={child.id === newFolderId}
|
||||
onDrop={handleDropOnChildren}
|
||||
operations={childrenOperations}
|
||||
dropEffect={handleDropEffectOnChildren}
|
||||
canDrop={handleChildrenCanDrop}
|
||||
location={{
|
||||
at: 'explorer:organize:folder-node',
|
||||
nodeId: child.id as string,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ExplorerTreeNode>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { MenuItem } from '@affine/component';
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const FavoriteFolderOperation = ({ id }: { id: string }) => {
|
||||
const t = useI18n();
|
||||
const compatibleFavoriteItemsAdapter = useService(
|
||||
CompatibleFavoriteItemsAdapter
|
||||
);
|
||||
|
||||
const favorite = useLiveData(
|
||||
useMemo(() => {
|
||||
return compatibleFavoriteItemsAdapter.isFavorite$(id, 'folder');
|
||||
}, [compatibleFavoriteItemsAdapter, id])
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
prefixIcon={<IsFavoriteIcon favorite={favorite} />}
|
||||
onClick={() => compatibleFavoriteItemsAdapter.toggle(id, 'folder')}
|
||||
>
|
||||
{favorite
|
||||
? t['com.affine.rootAppSidebar.organize.folder-rm-favorite']()
|
||||
: t['com.affine.rootAppSidebar.organize.folder-add-favorite']()}
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { type DropTargetDropEvent, useDropTarget } from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { EmptyNodeChildren } from '../../layouts/empty-node-children';
|
||||
|
||||
export const Empty = ({
|
||||
onDrop,
|
||||
}: {
|
||||
onDrop: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
}) => {
|
||||
const { dropTargetRef } = useDropTarget(
|
||||
() => ({
|
||||
onDrop,
|
||||
}),
|
||||
[onDrop]
|
||||
);
|
||||
const t = useI18n();
|
||||
return (
|
||||
<EmptyNodeChildren ref={dropTargetRef}>
|
||||
{t['com.affine.rootAppSidebar.tags.no-doc']()}
|
||||
</EmptyNodeChildren>
|
||||
);
|
||||
};
|
||||
@@ -1,211 +0,0 @@
|
||||
import {
|
||||
type DropTargetDropEvent,
|
||||
type DropTargetOptions,
|
||||
toast,
|
||||
} from '@affine/component';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
import type { Tag } from '@affine/core/modules/tag';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { ExplorerTreeNode, type ExplorerTreeNodeDropEffect } from '../../tree';
|
||||
import { ExplorerDocNode } from '../doc';
|
||||
import type { GenericExplorerNode } from '../types';
|
||||
import { Empty } from './empty';
|
||||
import { useExplorerTagNodeOperations } from './operations';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const ExplorerTagNode = ({
|
||||
tagId,
|
||||
onDrop,
|
||||
location,
|
||||
reorderable,
|
||||
operations: additionalOperations,
|
||||
dropEffect,
|
||||
canDrop,
|
||||
}: {
|
||||
tagId: string;
|
||||
} & GenericExplorerNode) => {
|
||||
const t = useI18n();
|
||||
const { tagService, globalContextService } = useServices({
|
||||
TagService,
|
||||
GlobalContextService,
|
||||
});
|
||||
const active =
|
||||
useLiveData(globalContextService.globalContext.tagId.$) === tagId;
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
||||
const tagColor = useLiveData(tagRecord?.color$);
|
||||
const tagName = useLiveData(tagRecord?.value$);
|
||||
|
||||
const Icon = useCallback(
|
||||
({ className }: { className?: string }) => {
|
||||
return (
|
||||
<div className={clsx(styles.tagIconContainer, className)}>
|
||||
<div
|
||||
className={styles.tagIcon}
|
||||
style={{
|
||||
backgroundColor: tagColor,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[tagColor]
|
||||
);
|
||||
|
||||
const dndData = useMemo(() => {
|
||||
return {
|
||||
draggable: {
|
||||
entity: {
|
||||
type: 'tag',
|
||||
id: tagId,
|
||||
},
|
||||
from: location,
|
||||
},
|
||||
dropTarget: {
|
||||
at: 'explorer:tag',
|
||||
},
|
||||
} satisfies AffineDNDData;
|
||||
}, [location, tagId]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(newName: string) => {
|
||||
if (tagRecord && tagRecord.value$.value !== newName) {
|
||||
tagRecord.rename(newName);
|
||||
track.$.navigationPanel.organize.renameOrganizeItem({
|
||||
type: 'tag',
|
||||
});
|
||||
}
|
||||
},
|
||||
[tagRecord]
|
||||
);
|
||||
|
||||
const handleDropOnTag = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (data.treeInstruction?.type === 'make-child' && tagRecord) {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
tagRecord.tag(data.source.data.entity.id);
|
||||
track.$.navigationPanel.tags.tagDoc({
|
||||
control: 'drag',
|
||||
});
|
||||
track.$.navigationPanel.tags.drop({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
} else {
|
||||
toast(t['com.affine.rootAppSidebar.tag.doc-only']());
|
||||
}
|
||||
} else {
|
||||
onDrop?.(data);
|
||||
}
|
||||
},
|
||||
[onDrop, t, tagRecord]
|
||||
);
|
||||
|
||||
const handleDropEffectOnTag = useCallback<ExplorerTreeNodeDropEffect>(
|
||||
data => {
|
||||
if (data.treeInstruction?.type === 'make-child') {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
return 'link';
|
||||
}
|
||||
} else {
|
||||
return dropEffect?.(data);
|
||||
}
|
||||
return;
|
||||
},
|
||||
[dropEffect]
|
||||
);
|
||||
|
||||
const handleDropOnPlaceholder = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (tagRecord) {
|
||||
if (data.source.data.entity?.type === 'doc') {
|
||||
tagRecord.tag(data.source.data.entity.id);
|
||||
} else {
|
||||
toast(t['com.affine.rootAppSidebar.tag.doc-only']());
|
||||
}
|
||||
}
|
||||
},
|
||||
[t, tagRecord]
|
||||
);
|
||||
|
||||
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||
() => args => {
|
||||
const entityType = args.source.data.entity?.type;
|
||||
return args.treeInstruction?.type !== 'make-child'
|
||||
? ((typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true)
|
||||
: entityType === 'doc';
|
||||
},
|
||||
[canDrop]
|
||||
);
|
||||
|
||||
const operations = useExplorerTagNodeOperations(
|
||||
tagId,
|
||||
useMemo(
|
||||
() => ({
|
||||
openNodeCollapsed: () => setCollapsed(false),
|
||||
}),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
const finalOperations = useMemo(() => {
|
||||
if (additionalOperations) {
|
||||
return [...operations, ...additionalOperations];
|
||||
}
|
||||
return operations;
|
||||
}, [additionalOperations, operations]);
|
||||
|
||||
if (!tagRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ExplorerTreeNode
|
||||
icon={Icon}
|
||||
name={tagName || t['Untitled']()}
|
||||
dndData={dndData}
|
||||
onDrop={handleDropOnTag}
|
||||
renameable
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
to={`/tag/${tagId}`}
|
||||
active={active}
|
||||
reorderable={reorderable}
|
||||
onRename={handleRename}
|
||||
canDrop={handleCanDrop}
|
||||
childrenPlaceholder={<Empty onDrop={handleDropOnPlaceholder} />}
|
||||
operations={finalOperations}
|
||||
dropEffect={handleDropEffectOnTag}
|
||||
data-testid={`explorer-tag-${tagId}`}
|
||||
>
|
||||
<ExplorerTagNodeDocs tag={tagRecord} />
|
||||
</ExplorerTreeNode>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* the `tag.pageIds$` has a performance issue,
|
||||
* so we split the tag node children into a separate component,
|
||||
* so it won't be rendered when the tag node is collapsed.
|
||||
*/
|
||||
export const ExplorerTagNodeDocs = ({ tag }: { tag: Tag }) => {
|
||||
const tagDocIds = useLiveData(tag.pageIds$);
|
||||
|
||||
return tagDocIds.map(docId => (
|
||||
<ExplorerDocNode
|
||||
key={docId}
|
||||
docId={docId}
|
||||
reorderable={false}
|
||||
location={{
|
||||
at: 'explorer:tags:docs',
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
@@ -1,165 +0,0 @@
|
||||
import { IconButton, MenuItem, MenuSeparator, toast } from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
|
||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { FavoriteService } from '@affine/core/modules/favorite';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import {
|
||||
DeleteIcon,
|
||||
OpenInNewIcon,
|
||||
PlusIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { NodeOperation } from '../../tree/types';
|
||||
|
||||
export const useExplorerTagNodeOperations = (
|
||||
tagId: string,
|
||||
{
|
||||
openNodeCollapsed,
|
||||
}: {
|
||||
openNodeCollapsed: () => void;
|
||||
}
|
||||
): NodeOperation[] => {
|
||||
const t = useI18n();
|
||||
const { workbenchService, workspaceService, tagService, favoriteService } =
|
||||
useServices({
|
||||
WorkbenchService,
|
||||
WorkspaceService,
|
||||
TagService,
|
||||
DocsService,
|
||||
FavoriteService,
|
||||
});
|
||||
|
||||
const favorite = useLiveData(
|
||||
favoriteService.favoriteList.favorite$('tag', tagId)
|
||||
);
|
||||
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
|
||||
|
||||
const { createPage } = usePageHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
|
||||
const handleNewDoc = useCallback(() => {
|
||||
if (tagRecord) {
|
||||
const newDoc = createPage();
|
||||
tagRecord?.tag(newDoc.id);
|
||||
track.$.navigationPanel.tags.createDoc();
|
||||
openNodeCollapsed();
|
||||
}
|
||||
}, [createPage, openNodeCollapsed, tagRecord]);
|
||||
|
||||
const handleMoveToTrash = useCallback(() => {
|
||||
tagService.tagList.deleteTag(tagId);
|
||||
track.$.navigationPanel.organize.deleteOrganizeItem({ type: 'tag' });
|
||||
toast(t['com.affine.tags.delete-tags.toast']());
|
||||
}, [t, tagId, tagService.tagList]);
|
||||
|
||||
const handleOpenInSplitView = useCallback(() => {
|
||||
workbenchService.workbench.openTag(tagId, {
|
||||
at: 'beside',
|
||||
});
|
||||
track.$.navigationPanel.organize.openInSplitView({ type: 'tag' });
|
||||
}, [tagId, workbenchService]);
|
||||
|
||||
const handleToggleFavoriteTag = useCallback(() => {
|
||||
favoriteService.favoriteList.toggle('tag', tagId);
|
||||
track.$.navigationPanel.organize.toggleFavorite({
|
||||
type: 'tag',
|
||||
});
|
||||
}, [favoriteService, tagId]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
workbenchService.workbench.openTag(tagId, {
|
||||
at: 'new-tab',
|
||||
});
|
||||
track.$.navigationPanel.organize.openInNewTab({ type: 'tag' });
|
||||
}, [tagId, workbenchService]);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
index: 0,
|
||||
inline: true,
|
||||
view: (
|
||||
<IconButton
|
||||
size="16"
|
||||
onClick={handleNewDoc}
|
||||
data-testid="tag-add-doc-button"
|
||||
tooltip={t['com.affine.rootAppSidebar.explorer.tag-add-tooltip']()}
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 50,
|
||||
view: (
|
||||
<MenuItem prefixIcon={<OpenInNewIcon />} onClick={handleOpenInNewTab}>
|
||||
{t['com.affine.workbench.tab.page-menu-open']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
...(BUILD_CONFIG.isElectron
|
||||
? [
|
||||
{
|
||||
index: 100,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<SplitViewIcon />}
|
||||
onClick={handleOpenInSplitView}
|
||||
>
|
||||
{t['com.affine.workbench.split-view.page-menu-open']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
index: 199,
|
||||
view: (
|
||||
<MenuItem
|
||||
prefixIcon={<IsFavoriteIcon favorite={!!favorite} />}
|
||||
onClick={handleToggleFavoriteTag}
|
||||
>
|
||||
{favorite
|
||||
? t['com.affine.favoritePageOperation.remove']()
|
||||
: t['com.affine.favoritePageOperation.add']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
{
|
||||
index: 9999,
|
||||
view: <MenuSeparator key="menu-separator" />,
|
||||
},
|
||||
{
|
||||
index: 10000,
|
||||
view: (
|
||||
<MenuItem
|
||||
type={'danger'}
|
||||
prefixIcon={<DeleteIcon />}
|
||||
onClick={handleMoveToTrash}
|
||||
data-testid="tag-delete-button"
|
||||
>
|
||||
{t['Delete']()}
|
||||
</MenuItem>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
favorite,
|
||||
handleMoveToTrash,
|
||||
handleNewDoc,
|
||||
handleOpenInNewTab,
|
||||
handleOpenInSplitView,
|
||||
handleToggleFavoriteTag,
|
||||
t,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tagIcon = style({
|
||||
borderRadius: '50%',
|
||||
height: '8px',
|
||||
width: '8px',
|
||||
});
|
||||
|
||||
export const tagIconContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { DropTargetDropEvent, DropTargetOptions } from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
|
||||
import type { ExplorerTreeNodeDropEffect } from '../tree';
|
||||
import type { NodeOperation } from '../tree/types';
|
||||
|
||||
/**
|
||||
* The interface for a generic explorer node.
|
||||
*
|
||||
* # Drop controlled area
|
||||
*
|
||||
* When an element is dragged over the node, there are two controlled areas depending on the mouse position.
|
||||
*
|
||||
* **Make Child Area**:
|
||||
* When the mouse is in the center area of the node, it is in `Make Child Area`,
|
||||
* `canDrop`, `onDrop`, and `dropEffect` are handled by the node itself.
|
||||
*
|
||||
* **Edge Area**:
|
||||
* When the mouse is at the upper edge, lower edge, or front of a node, it is located in the `Edge Area`,
|
||||
* and all drop events are handled by the node's parent, which callbacks in this interface.
|
||||
*
|
||||
* The controlled area can be distinguished by `data.treeInstruction.type` in the callback parameter.
|
||||
*/
|
||||
export interface GenericExplorerNode {
|
||||
/**
|
||||
* Tell the node and dropTarget where the node is located in the tree
|
||||
*/
|
||||
location?: AffineDNDData['draggable']['from'];
|
||||
/**
|
||||
* Whether the node is allowed to reorder with its sibling nodes
|
||||
*/
|
||||
reorderable?: boolean;
|
||||
/**
|
||||
* Additional operations to be displayed in the node
|
||||
*/
|
||||
operations?: NodeOperation[];
|
||||
/**
|
||||
* Control whether drop is allowed, the callback will be called when dragging.
|
||||
*/
|
||||
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||
/**
|
||||
* Called when an element is dropped over the node.
|
||||
*/
|
||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
/**
|
||||
* The drop effect to be used when an element is dropped over the node.
|
||||
*/
|
||||
dropEffect?: ExplorerTreeNodeDropEffect;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { ExplorerEmptySection } from '../../layouts/empty-section';
|
||||
|
||||
export const RootEmpty = ({
|
||||
onClickCreate,
|
||||
}: {
|
||||
onClickCreate?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<ExplorerEmptySection
|
||||
icon={ViewLayersIcon}
|
||||
message={t['com.affine.collections.empty.message']()}
|
||||
messageTestId="slider-bar-collection-empty-message"
|
||||
actionText={t['com.affine.collections.empty.new-collection-button']()}
|
||||
onActionClick={onClickCreate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const createTips = style({
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { IconButton, usePromptModal } from '@affine/component';
|
||||
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { AddCollectionIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { ExplorerService } from '../../../services/explorer';
|
||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||
import { RootEmpty } from './empty';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const ExplorerCollections = () => {
|
||||
const t = useI18n();
|
||||
const { collectionService, workbenchService, explorerService } = useServices({
|
||||
CollectionService,
|
||||
WorkbenchService,
|
||||
ExplorerService,
|
||||
});
|
||||
const explorerSection = explorerService.sections.collections;
|
||||
const collections = useLiveData(collectionService.collections$);
|
||||
const { openPromptModal } = usePromptModal();
|
||||
|
||||
const handleCreateCollection = useCallback(() => {
|
||||
openPromptModal({
|
||||
title: t['com.affine.editCollection.saveCollection'](),
|
||||
label: t['com.affine.editCollectionName.name'](),
|
||||
inputOptions: {
|
||||
placeholder: t['com.affine.editCollectionName.name.placeholder'](),
|
||||
},
|
||||
children: (
|
||||
<div className={styles.createTips}>
|
||||
{t['com.affine.editCollectionName.createTips']()}
|
||||
</div>
|
||||
),
|
||||
confirmText: t['com.affine.editCollection.save'](),
|
||||
cancelText: t['com.affine.editCollection.button.cancel'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'primary',
|
||||
},
|
||||
onConfirm(name) {
|
||||
const id = nanoid();
|
||||
collectionService.addCollection(createEmptyCollection(id, { name }));
|
||||
track.$.navigationPanel.organize.createOrganizeItem({
|
||||
type: 'collection',
|
||||
});
|
||||
workbenchService.workbench.openCollection(id);
|
||||
explorerSection.setCollapsed(false);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
collectionService,
|
||||
explorerSection,
|
||||
openPromptModal,
|
||||
t,
|
||||
workbenchService.workbench,
|
||||
]);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
name="collections"
|
||||
testId="explorer-collections"
|
||||
title={t['com.affine.rootAppSidebar.collections']()}
|
||||
actions={
|
||||
<IconButton
|
||||
data-testid="explorer-bar-add-collection-button"
|
||||
onClick={handleCreateCollection}
|
||||
size="16"
|
||||
tooltip={t[
|
||||
'com.affine.rootAppSidebar.explorer.collection-section-add-tooltip'
|
||||
]()}
|
||||
>
|
||||
<AddCollectionIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ExplorerTreeRoot
|
||||
placeholder={<RootEmpty onClickCreate={handleCreateCollection} />}
|
||||
>
|
||||
{collections.map(collection => (
|
||||
<ExplorerCollectionNode
|
||||
key={collection.id}
|
||||
collectionId={collection.id}
|
||||
reorderable={false}
|
||||
location={{
|
||||
at: 'explorer:collection:list',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ExplorerTreeRoot>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { DropTargetOptions } from '@affine/component';
|
||||
import { isFavoriteSupportType } from '@affine/core/modules/favorite';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
|
||||
import type { ExplorerTreeNodeDropEffect } from '../../tree';
|
||||
|
||||
export const favoriteChildrenDropEffect: ExplorerTreeNodeDropEffect = data => {
|
||||
if (
|
||||
data.treeInstruction?.type === 'reorder-above' ||
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
if (
|
||||
data.source.data.from?.at === 'explorer:favorite:list' &&
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
return 'move';
|
||||
} else if (
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
return 'link';
|
||||
}
|
||||
}
|
||||
return; // not supported
|
||||
};
|
||||
|
||||
export const favoriteRootDropEffect: ExplorerTreeNodeDropEffect = data => {
|
||||
const sourceType = data.source.data.entity?.type;
|
||||
if (sourceType && isFavoriteSupportType(sourceType)) {
|
||||
return 'link';
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
export const favoriteRootCanDrop: DropTargetOptions<AffineDNDData>['canDrop'] =
|
||||
data => {
|
||||
return data.source.data.entity?.type
|
||||
? isFavoriteSupportType(data.source.data.entity.type)
|
||||
: false;
|
||||
};
|
||||
|
||||
export const favoriteChildrenCanDrop: DropTargetOptions<AffineDNDData>['canDrop'] =
|
||||
// Same as favoriteRootCanDrop
|
||||
data => favoriteRootCanDrop(data);
|
||||
@@ -1,60 +0,0 @@
|
||||
import {
|
||||
type DropTargetDropEvent,
|
||||
Skeleton,
|
||||
useDropTarget,
|
||||
} from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { FavoriteIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { ExplorerEmptySection } from '../../layouts/empty-section';
|
||||
import { DropEffect } from '../../tree';
|
||||
import { favoriteRootCanDrop, favoriteRootDropEffect } from './dnd';
|
||||
|
||||
interface RootEmptyProps {
|
||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const RootEmptyLoading = () => {
|
||||
return <Skeleton />;
|
||||
};
|
||||
const RootEmptyReady = ({ onDrop }: Omit<RootEmptyProps, 'isLoading'>) => {
|
||||
const t = useI18n();
|
||||
|
||||
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
|
||||
useDropTarget<AffineDNDData>(
|
||||
() => ({
|
||||
data: {
|
||||
at: 'explorer:favorite:root',
|
||||
},
|
||||
onDrop: onDrop,
|
||||
canDrop: favoriteRootCanDrop,
|
||||
allowExternal: true,
|
||||
}),
|
||||
[onDrop]
|
||||
);
|
||||
|
||||
return (
|
||||
<ExplorerEmptySection
|
||||
ref={dropTargetRef}
|
||||
icon={FavoriteIcon}
|
||||
message={t['com.affine.rootAppSidebar.favorites.empty']()}
|
||||
messageTestId="slider-bar-favorites-empty-message"
|
||||
>
|
||||
{draggedOverDraggable && (
|
||||
<DropEffect
|
||||
position={draggedOverPosition}
|
||||
dropEffect={favoriteRootDropEffect({
|
||||
source: draggedOverDraggable,
|
||||
treeInstruction: null,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</ExplorerEmptySection>
|
||||
);
|
||||
};
|
||||
|
||||
export const RootEmpty = ({ isLoading, ...props }: RootEmptyProps) => {
|
||||
return isLoading ? <RootEmptyLoading /> : <RootEmptyReady {...props} />;
|
||||
};
|
||||
@@ -1,276 +0,0 @@
|
||||
import {
|
||||
type DropTargetDropEvent,
|
||||
IconButton,
|
||||
useDropTarget,
|
||||
} from '@affine/component';
|
||||
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
|
||||
import {
|
||||
DropEffect,
|
||||
ExplorerTreeRoot,
|
||||
} from '@affine/core/modules/explorer/views/tree';
|
||||
import type { FavoriteSupportTypeUnion } from '@affine/core/modules/favorite';
|
||||
import {
|
||||
FavoriteService,
|
||||
isFavoriteSupportType,
|
||||
} from '@affine/core/modules/favorite';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { inferOpenMode } from '@affine/core/utils';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { type MouseEventHandler, useCallback } from 'react';
|
||||
|
||||
import { ExplorerService } from '../../../services/explorer';
|
||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||
import { ExplorerDocNode } from '../../nodes/doc';
|
||||
import { ExplorerFolderNode } from '../../nodes/folder';
|
||||
import { ExplorerTagNode } from '../../nodes/tag';
|
||||
import {
|
||||
favoriteChildrenCanDrop,
|
||||
favoriteChildrenDropEffect,
|
||||
favoriteRootCanDrop,
|
||||
favoriteRootDropEffect,
|
||||
} from './dnd';
|
||||
import { RootEmpty } from './empty';
|
||||
|
||||
export const ExplorerFavorites = () => {
|
||||
const { favoriteService, workspaceService, explorerService } = useServices({
|
||||
FavoriteService,
|
||||
WorkspaceService,
|
||||
ExplorerService,
|
||||
});
|
||||
|
||||
const explorerSection = explorerService.sections.favorites;
|
||||
|
||||
const favorites = useLiveData(favoriteService.favoriteList.sortedList$);
|
||||
|
||||
const isLoading = useLiveData(favoriteService.favoriteList.isLoading$);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const { createPage } = usePageHelper(
|
||||
workspaceService.workspace.docCollection
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
if (
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
favoriteService.favoriteList.add(
|
||||
data.source.data.entity.type,
|
||||
data.source.data.entity.id,
|
||||
favoriteService.favoriteList.indexAt('before')
|
||||
);
|
||||
track.$.navigationPanel.organize.toggleFavorite({
|
||||
type: data.source.data.entity.type,
|
||||
on: true,
|
||||
});
|
||||
track.$.navigationPanel.favorites.drop({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
explorerSection.setCollapsed(false);
|
||||
}
|
||||
},
|
||||
[explorerSection, favoriteService.favoriteList]
|
||||
);
|
||||
|
||||
const handleCreateNewFavoriteDoc: MouseEventHandler = useCallback(
|
||||
e => {
|
||||
const newDoc = createPage(undefined, { at: inferOpenMode(e) });
|
||||
favoriteService.favoriteList.add(
|
||||
'doc',
|
||||
newDoc.id,
|
||||
favoriteService.favoriteList.indexAt('before')
|
||||
);
|
||||
explorerSection.setCollapsed(false);
|
||||
},
|
||||
[createPage, explorerSection, favoriteService.favoriteList]
|
||||
);
|
||||
|
||||
const handleOnChildrenDrop = useCallback(
|
||||
(
|
||||
favorite: { id: string; type: FavoriteSupportTypeUnion },
|
||||
data: DropTargetDropEvent<AffineDNDData>
|
||||
) => {
|
||||
if (
|
||||
data.treeInstruction?.type === 'reorder-above' ||
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
if (
|
||||
data.source.data.from?.at === 'explorer:favorite:list' &&
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
// is reordering
|
||||
favoriteService.favoriteList.reorder(
|
||||
data.source.data.entity.type,
|
||||
data.source.data.entity.id,
|
||||
favoriteService.favoriteList.indexAt(
|
||||
data.treeInstruction?.type === 'reorder-above'
|
||||
? 'before'
|
||||
: 'after',
|
||||
favorite
|
||||
)
|
||||
);
|
||||
track.$.navigationPanel.organize.orderOrganizeItem({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
} else if (
|
||||
data.source.data.entity?.type &&
|
||||
isFavoriteSupportType(data.source.data.entity.type)
|
||||
) {
|
||||
favoriteService.favoriteList.add(
|
||||
data.source.data.entity.type,
|
||||
data.source.data.entity.id,
|
||||
favoriteService.favoriteList.indexAt(
|
||||
data.treeInstruction?.type === 'reorder-above'
|
||||
? 'before'
|
||||
: 'after',
|
||||
favorite
|
||||
)
|
||||
);
|
||||
track.$.navigationPanel.organize.toggleFavorite({
|
||||
type: data.source.data.entity.type,
|
||||
on: true,
|
||||
});
|
||||
track.$.navigationPanel.favorites.drop({
|
||||
type: data.source.data.entity.type,
|
||||
});
|
||||
} else {
|
||||
return; // not supported
|
||||
}
|
||||
}
|
||||
},
|
||||
[favoriteService]
|
||||
);
|
||||
|
||||
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
|
||||
useDropTarget<AffineDNDData>(
|
||||
() => ({
|
||||
data: {
|
||||
at: 'explorer:favorite:root',
|
||||
},
|
||||
onDrop: handleDrop,
|
||||
canDrop: favoriteRootCanDrop,
|
||||
allowExternal: true,
|
||||
}),
|
||||
[handleDrop]
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
name="favorites"
|
||||
title={t['com.affine.rootAppSidebar.favorites']()}
|
||||
headerRef={dropTargetRef}
|
||||
testId="explorer-favorites"
|
||||
headerTestId="explorer-favorite-category-divider"
|
||||
actions={
|
||||
<>
|
||||
<IconButton
|
||||
data-testid="explorer-bar-add-favorite-button"
|
||||
data-event-props="$.navigationPanel.favorites.createDoc"
|
||||
data-event-args-control="addFavorite"
|
||||
onClick={handleCreateNewFavoriteDoc}
|
||||
onAuxClick={handleCreateNewFavoriteDoc}
|
||||
size="16"
|
||||
tooltip={t[
|
||||
'com.affine.rootAppSidebar.explorer.fav-section-add-tooltip'
|
||||
]()}
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
{draggedOverDraggable && (
|
||||
<DropEffect
|
||||
position={draggedOverPosition}
|
||||
dropEffect={favoriteRootDropEffect({
|
||||
source: draggedOverDraggable,
|
||||
treeInstruction: null,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ExplorerTreeRoot
|
||||
placeholder={<RootEmpty onDrop={handleDrop} isLoading={isLoading} />}
|
||||
>
|
||||
{favorites.map(favorite => (
|
||||
<ExplorerFavoriteNode
|
||||
key={favorite.id}
|
||||
favorite={favorite}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
/>
|
||||
))}
|
||||
</ExplorerTreeRoot>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
|
||||
const childLocation = {
|
||||
at: 'explorer:favorite:list' as const,
|
||||
};
|
||||
const ExplorerFavoriteNode = ({
|
||||
favorite,
|
||||
onDrop,
|
||||
}: {
|
||||
favorite: {
|
||||
id: string;
|
||||
type: FavoriteSupportTypeUnion;
|
||||
};
|
||||
onDrop: (
|
||||
favorite: {
|
||||
id: string;
|
||||
type: FavoriteSupportTypeUnion;
|
||||
},
|
||||
data: DropTargetDropEvent<AffineDNDData>
|
||||
) => void;
|
||||
}) => {
|
||||
const handleOnChildrenDrop = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
onDrop(favorite, data);
|
||||
},
|
||||
[favorite, onDrop]
|
||||
);
|
||||
return favorite.type === 'doc' ? (
|
||||
<ExplorerDocNode
|
||||
key={favorite.id}
|
||||
docId={favorite.id}
|
||||
location={childLocation}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={favoriteChildrenDropEffect}
|
||||
canDrop={favoriteChildrenCanDrop}
|
||||
/>
|
||||
) : favorite.type === 'tag' ? (
|
||||
<ExplorerTagNode
|
||||
key={favorite.id}
|
||||
tagId={favorite.id}
|
||||
location={childLocation}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={favoriteChildrenDropEffect}
|
||||
canDrop={favoriteChildrenCanDrop}
|
||||
/>
|
||||
) : favorite.type === 'folder' ? (
|
||||
<ExplorerFolderNode
|
||||
key={favorite.id}
|
||||
nodeId={favorite.id}
|
||||
location={childLocation}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={favoriteChildrenDropEffect}
|
||||
canDrop={favoriteChildrenCanDrop}
|
||||
/>
|
||||
) : (
|
||||
<ExplorerCollectionNode
|
||||
key={favorite.id}
|
||||
collectionId={favorite.id}
|
||||
location={childLocation}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={favoriteChildrenDropEffect}
|
||||
canDrop={favoriteChildrenCanDrop}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
import { IconButton, useConfirmModal } from '@affine/component';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
|
||||
import { MigrationFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { BroomIcon, HelpIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||
import { ExplorerCollectionNode } from '../../nodes/collection';
|
||||
import { ExplorerDocNode } from '../../nodes/doc';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const ExplorerMigrationFavorites = () => {
|
||||
const t = useI18n();
|
||||
|
||||
const { migrationFavoriteItemsAdapter, docsService } = useServices({
|
||||
MigrationFavoriteItemsAdapter,
|
||||
DocsService,
|
||||
});
|
||||
|
||||
const docs = useLiveData(docsService.list.docs$);
|
||||
const trashDocs = useLiveData(docsService.list.trashDocs$);
|
||||
const migrated = useLiveData(migrationFavoriteItemsAdapter.migrated$);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const favorites = useLiveData(
|
||||
migrationFavoriteItemsAdapter.favorites$.map(favs => {
|
||||
return favs.filter(fav => {
|
||||
if (fav.type === 'doc') {
|
||||
return (
|
||||
docs.some(doc => doc.id === fav.id) &&
|
||||
!trashDocs.some(doc => doc.id === fav.id)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const handleClickClear = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.rootAppSidebar.migration-data.clean-all'](),
|
||||
description: (
|
||||
<Trans
|
||||
i18nKey="com.affine.rootAppSidebar.migration-data.clean-all.description"
|
||||
components={{
|
||||
b: <b className={styles.descriptionHighlight} />,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
confirmText:
|
||||
t['com.affine.rootAppSidebar.migration-data.clean-all.confirm'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'primary',
|
||||
},
|
||||
cancelText:
|
||||
t['com.affine.rootAppSidebar.migration-data.clean-all.cancel'](),
|
||||
onConfirm() {
|
||||
migrationFavoriteItemsAdapter.markFavoritesMigrated();
|
||||
},
|
||||
});
|
||||
}, [migrationFavoriteItemsAdapter, openConfirmModal, t]);
|
||||
|
||||
const handleClickHelp = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.rootAppSidebar.migration-data.help'](),
|
||||
description: (
|
||||
<Trans
|
||||
i18nKey="com.affine.rootAppSidebar.migration-data.help.description"
|
||||
components={{
|
||||
b: <b className={styles.descriptionHighlight} />,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
confirmText: t['com.affine.rootAppSidebar.migration-data.help.confirm'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'primary',
|
||||
},
|
||||
cancelText:
|
||||
t['com.affine.rootAppSidebar.migration-data.help.clean-all'](),
|
||||
cancelButtonOptions: {
|
||||
prefix: <BroomIcon />,
|
||||
onClick: () => {
|
||||
requestAnimationFrame(() => {
|
||||
handleClickClear();
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
track.$.navigationPanel.migrationData.openMigrationDataHelp();
|
||||
}, [handleClickClear, openConfirmModal, t]);
|
||||
|
||||
if (favorites.length === 0 || migrated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
name="migrationFavorites"
|
||||
className={styles.container}
|
||||
title={t['com.affine.rootAppSidebar.migration-data']()}
|
||||
actions={
|
||||
<>
|
||||
<IconButton
|
||||
data-testid="explorer-bar-favorite-migration-clear-button"
|
||||
onClick={handleClickClear}
|
||||
size="16"
|
||||
>
|
||||
<BroomIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
data-testid="explorer-bar-favorite-migration-help-button"
|
||||
size="16"
|
||||
onClick={handleClickHelp}
|
||||
>
|
||||
<HelpIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ExplorerTreeRoot>
|
||||
{favorites.map((favorite, i) => (
|
||||
<ExplorerMigrationFavoriteNode
|
||||
key={favorite.id + ':' + i}
|
||||
favorite={favorite}
|
||||
/>
|
||||
))}
|
||||
</ExplorerTreeRoot>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
|
||||
const childLocation = {
|
||||
at: 'explorer:migration-data:list' as const,
|
||||
};
|
||||
const ExplorerMigrationFavoriteNode = ({
|
||||
favorite,
|
||||
}: {
|
||||
favorite: {
|
||||
id: string;
|
||||
type: 'collection' | 'doc';
|
||||
};
|
||||
}) => {
|
||||
return favorite.type === 'doc' ? (
|
||||
<ExplorerDocNode
|
||||
key={favorite.id}
|
||||
docId={favorite.id}
|
||||
location={childLocation}
|
||||
reorderable={false}
|
||||
canDrop={false}
|
||||
/>
|
||||
) : (
|
||||
<ExplorerCollectionNode
|
||||
key={favorite.id}
|
||||
collectionId={favorite.id}
|
||||
location={childLocation}
|
||||
reorderable={false}
|
||||
canDrop={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const container = style({
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&[data-collapsed="false"]:after': {
|
||||
display: 'block',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: '-8px',
|
||||
top: '0',
|
||||
width: '6px',
|
||||
height: '100%',
|
||||
background:
|
||||
'repeating-linear-gradient(30deg, #F5CC47, #F5CC47 8px, #000000 8px, #000000 14px)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const descriptionHighlight = style({
|
||||
fontWeight: 'bold',
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { DropTargetOptions } from '@affine/component';
|
||||
import { isOrganizeSupportType } from '@affine/core/modules/organize/constants';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
|
||||
import type { ExplorerTreeNodeDropEffect } from '../../tree';
|
||||
|
||||
export const organizeChildrenDropEffect: ExplorerTreeNodeDropEffect = data => {
|
||||
if (
|
||||
data.treeInstruction?.type === 'reorder-above' ||
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
if (data.source.data.entity?.type === 'folder') {
|
||||
return 'move';
|
||||
}
|
||||
} else {
|
||||
return; // not supported
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
export const organizeEmptyDropEffect: ExplorerTreeNodeDropEffect = data => {
|
||||
const sourceType = data.source.data.entity?.type;
|
||||
if (sourceType && isOrganizeSupportType(sourceType)) {
|
||||
return 'link';
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether the data can be dropped on the empty state of the organize section
|
||||
*/
|
||||
export const organizeEmptyRootCanDrop: DropTargetOptions<AffineDNDData>['canDrop'] =
|
||||
data => {
|
||||
const type = data.source.data.entity?.type;
|
||||
return !!type && isOrganizeSupportType(type);
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
AnimatedFolderIcon,
|
||||
type DropTargetDropEvent,
|
||||
Skeleton,
|
||||
useDropTarget,
|
||||
} from '@affine/component';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
|
||||
import { ExplorerEmptySection } from '../../layouts/empty-section';
|
||||
import { DropEffect } from '../../tree';
|
||||
import { organizeEmptyDropEffect, organizeEmptyRootCanDrop } from './dnd';
|
||||
|
||||
interface RootEmptyProps {
|
||||
onClickCreate?: () => void;
|
||||
isLoading?: boolean;
|
||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
}
|
||||
|
||||
export const RootEmptyLoading = () => {
|
||||
return <Skeleton />;
|
||||
};
|
||||
|
||||
export const RootEmptyReady = ({
|
||||
onClickCreate,
|
||||
onDrop,
|
||||
}: Omit<RootEmptyProps, 'isLoading'>) => {
|
||||
const t = useI18n();
|
||||
|
||||
const { dropTargetRef, draggedOverDraggable, draggedOverPosition } =
|
||||
useDropTarget<AffineDNDData>(
|
||||
() => ({
|
||||
data: { at: 'explorer:organize:root' },
|
||||
onDrop,
|
||||
canDrop: organizeEmptyRootCanDrop,
|
||||
}),
|
||||
[onDrop]
|
||||
);
|
||||
|
||||
return (
|
||||
<ExplorerEmptySection
|
||||
ref={dropTargetRef}
|
||||
icon={<AnimatedFolderIcon open={!!draggedOverDraggable} />}
|
||||
message={t['com.affine.rootAppSidebar.organize.empty']()}
|
||||
messageTestId="slider-bar-organize-empty-message"
|
||||
actionText={t[
|
||||
'com.affine.rootAppSidebar.organize.empty.new-folders-button'
|
||||
]()}
|
||||
onActionClick={onClickCreate}
|
||||
>
|
||||
{draggedOverDraggable && (
|
||||
<DropEffect
|
||||
position={draggedOverPosition}
|
||||
dropEffect={organizeEmptyDropEffect({
|
||||
source: draggedOverDraggable,
|
||||
treeInstruction: null,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</ExplorerEmptySection>
|
||||
);
|
||||
};
|
||||
|
||||
export const RootEmpty = ({ isLoading, ...props }: RootEmptyProps) => {
|
||||
return isLoading ? <RootEmptyLoading /> : <RootEmptyReady {...props} />;
|
||||
};
|
||||
@@ -1,149 +0,0 @@
|
||||
import {
|
||||
type DropTargetDropEvent,
|
||||
type DropTargetOptions,
|
||||
IconButton,
|
||||
toast,
|
||||
} from '@affine/component';
|
||||
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
|
||||
import {
|
||||
type FolderNode,
|
||||
OrganizeService,
|
||||
} from '@affine/core/modules/organize';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { AddOrganizeIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ExplorerService } from '../../../services/explorer';
|
||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||
import { ExplorerFolderNode } from '../../nodes/folder';
|
||||
import { organizeChildrenDropEffect } from './dnd';
|
||||
import { RootEmpty } from './empty';
|
||||
|
||||
export const ExplorerOrganize = () => {
|
||||
const { organizeService, explorerService } = useServices({
|
||||
OrganizeService,
|
||||
ExplorerService,
|
||||
});
|
||||
const explorerSection = explorerService.sections.organize;
|
||||
const collapsed = useLiveData(explorerSection.collapsed$);
|
||||
const [newFolderId, setNewFolderId] = useState<string | null>(null);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const folderTree = organizeService.folderTree;
|
||||
const rootFolder = folderTree.rootFolder;
|
||||
|
||||
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]);
|
||||
|
||||
const handleOnChildrenDrop = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>, node?: FolderNode) => {
|
||||
if (!node || !node.id) {
|
||||
return; // never happens
|
||||
}
|
||||
if (
|
||||
data.treeInstruction?.type === 'reorder-above' ||
|
||||
data.treeInstruction?.type === 'reorder-below'
|
||||
) {
|
||||
const at =
|
||||
data.treeInstruction?.type === 'reorder-below' ? 'after' : 'before';
|
||||
if (data.source.data.entity?.type === 'folder') {
|
||||
rootFolder.moveHere(
|
||||
data.source.data.entity.id,
|
||||
rootFolder.indexAt(at, node.id)
|
||||
);
|
||||
track.$.navigationPanel.organize.moveOrganizeItem({ type: 'folder' });
|
||||
} else {
|
||||
toast(t['com.affine.rootAppSidebar.organize.root-folder-only']());
|
||||
}
|
||||
} else {
|
||||
return; // not supported
|
||||
}
|
||||
},
|
||||
[rootFolder, t]
|
||||
);
|
||||
|
||||
const createFolderAndDrop = useCallback(
|
||||
(data: DropTargetDropEvent<AffineDNDData>) => {
|
||||
const newFolderId = handleCreateFolder();
|
||||
setNewFolderId(null);
|
||||
const newFolder$ = folderTree.folderNode$(newFolderId);
|
||||
|
||||
const entity = data.source.data.entity;
|
||||
if (!entity) return;
|
||||
const { type, id } = entity;
|
||||
if (type !== 'doc' && type !== 'tag' && type !== 'collection') return;
|
||||
|
||||
const folder = newFolder$.value;
|
||||
if (!folder) return;
|
||||
folder.createLink(type, id, folder.indexAt('after'));
|
||||
},
|
||||
[folderTree, handleCreateFolder]
|
||||
);
|
||||
|
||||
const handleChildrenCanDrop = useMemo<
|
||||
DropTargetOptions<AffineDNDData>['canDrop']
|
||||
>(() => args => args.source.data.entity?.type === 'folder', []);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) setNewFolderId(null); // reset new folder id to clear the renaming state
|
||||
}, [collapsed]);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
name="organize"
|
||||
title={t['com.affine.rootAppSidebar.organize']()}
|
||||
actions={
|
||||
<IconButton
|
||||
data-testid="explorer-bar-add-organize-button"
|
||||
onClick={handleCreateFolder}
|
||||
size="16"
|
||||
tooltip={t[
|
||||
'com.affine.rootAppSidebar.explorer.organize-section-add-tooltip'
|
||||
]()}
|
||||
>
|
||||
<AddOrganizeIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ExplorerTreeRoot
|
||||
placeholder={
|
||||
<RootEmpty
|
||||
onClickCreate={handleCreateFolder}
|
||||
isLoading={isLoading}
|
||||
onDrop={createFolderAndDrop}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{folders.map(child => (
|
||||
<ExplorerFolderNode
|
||||
key={child.id}
|
||||
nodeId={child.id as string}
|
||||
defaultRenaming={child.id === newFolderId}
|
||||
onDrop={handleOnChildrenDrop}
|
||||
dropEffect={organizeChildrenDropEffect}
|
||||
canDrop={handleChildrenCanDrop}
|
||||
location={{
|
||||
at: 'explorer:organize:folder-node',
|
||||
nodeId: child.id as string,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ExplorerTreeRoot>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { TagIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { ExplorerEmptySection } from '../../layouts/empty-section';
|
||||
|
||||
export const RootEmpty = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<ExplorerEmptySection
|
||||
icon={TagIcon}
|
||||
message={t['com.affine.rootAppSidebar.tags.empty']()}
|
||||
messageTestId="slider-bar-tags-empty-message"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
|
||||
import { TagService } from '@affine/core/modules/tag';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { AddTagIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ExplorerService } from '../../../services/explorer';
|
||||
import { CollapsibleSection } from '../../layouts/collapsible-section';
|
||||
import { ExplorerTagNode } from '../../nodes/tag';
|
||||
import { ExplorerTreeNodeRenameModal as CreateTagModal } from '../../tree/node';
|
||||
import { RootEmpty } from './empty';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const ExplorerTags = () => {
|
||||
const { tagService, explorerService } = useServices({
|
||||
TagService,
|
||||
ExplorerService,
|
||||
});
|
||||
const explorerSection = explorerService.sections.tags;
|
||||
const collapsed = useLiveData(explorerSection.collapsed$);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const tags = useLiveData(tagService.tagList.tags$);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const handleCreateNewTag = useCallback(
|
||||
(name: string) => {
|
||||
tagService.tagList.createTag(name, tagService.randomTagColor());
|
||||
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
|
||||
explorerSection.setCollapsed(false);
|
||||
},
|
||||
[explorerSection, tagService]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed) setCreating(false);
|
||||
}, [collapsed]);
|
||||
|
||||
const handleOpenCreateModal = useCallback(() => {
|
||||
setCreating(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
name="tags"
|
||||
testId="explorer-tags"
|
||||
headerClassName={styles.draggedOverHighlight}
|
||||
title={t['com.affine.rootAppSidebar.tags']()}
|
||||
actions={
|
||||
<div className={styles.iconContainer}>
|
||||
<IconButton
|
||||
data-testid="explorer-bar-add-tag-button"
|
||||
onClick={handleOpenCreateModal}
|
||||
size="16"
|
||||
tooltip={t[
|
||||
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
|
||||
]()}
|
||||
>
|
||||
<AddTagIcon />
|
||||
</IconButton>
|
||||
{creating && (
|
||||
<CreateTagModal
|
||||
setRenaming={setCreating}
|
||||
handleRename={handleCreateNewTag}
|
||||
rawName={t['com.affine.rootAppSidebar.tags.new-tag']()}
|
||||
className={styles.createModalAnchor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ExplorerTreeRoot placeholder={<RootEmpty />}>
|
||||
{tags.map(tag => (
|
||||
<ExplorerTagNode
|
||||
key={tag.id}
|
||||
tagId={tag.id}
|
||||
reorderable={false}
|
||||
location={{
|
||||
at: 'explorer:tags:list',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ExplorerTreeRoot>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const draggedOverHighlight = style({
|
||||
selectors: {
|
||||
'&[data-dragged-over="true"]': {
|
||||
background: cssVar('--affine-hover-color'),
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const iconContainer = style({
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const createModalAnchor = style({
|
||||
top: 20,
|
||||
left: 'auto',
|
||||
right: 0,
|
||||
transform: 'translateX(6px)',
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface ExplorerTreeContextData {
|
||||
/**
|
||||
* The level of the current tree node.
|
||||
*/
|
||||
level: number;
|
||||
}
|
||||
|
||||
export const ExplorerTreeContext =
|
||||
React.createContext<ExplorerTreeContextData | null>(null);
|
||||
@@ -1,24 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const dropEffect = style({
|
||||
zIndex: 99999,
|
||||
position: 'fixed',
|
||||
left: '10px',
|
||||
top: '-20px',
|
||||
background: cssVar('--affine-background-primary-color'),
|
||||
boxShadow: cssVar('--affine-toolbar-shadow'),
|
||||
padding: '0px 4px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '4px',
|
||||
lineHeight: 1.4,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const icon = style({
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { useDropTarget } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { CopyIcon, LinkIcon, MoveToIcon } from '@blocksuite/icons/rc';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import * as styles from './drop-effect.css';
|
||||
|
||||
export const DropEffect = ({
|
||||
dropEffect,
|
||||
position,
|
||||
}: {
|
||||
dropEffect?: 'copy' | 'move' | 'link' | undefined;
|
||||
position: ReturnType<typeof useDropTarget>['draggedOverPosition'];
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
if (dropEffect === undefined) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className={styles.dropEffect}
|
||||
style={{
|
||||
transform: `translate(${position.clientX}px, ${position.clientY}px)`,
|
||||
}}
|
||||
>
|
||||
{dropEffect === 'copy' ? (
|
||||
<CopyIcon className={styles.icon} />
|
||||
) : dropEffect === 'move' ? (
|
||||
<MoveToIcon className={styles.icon} />
|
||||
) : (
|
||||
<LinkIcon className={styles.icon} />
|
||||
)}
|
||||
{dropEffect === 'copy'
|
||||
? t['com.affine.rootAppSidebar.explorer.drop-effect.copy']()
|
||||
: dropEffect === 'move'
|
||||
? t['com.affine.rootAppSidebar.explorer.drop-effect.move']()
|
||||
: t['com.affine.rootAppSidebar.explorer.drop-effect.link']()}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export { DropEffect } from './drop-effect';
|
||||
export type {
|
||||
ExplorerTreeNodeDropEffect,
|
||||
ExplorerTreeNodeDropEffectData,
|
||||
} from './node';
|
||||
export { ExplorerTreeNode } from './node';
|
||||
export { ExplorerTreeRoot } from './root';
|
||||
@@ -1,181 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, keyframes, style } from '@vanilla-extract/css';
|
||||
export const levelIndent = createVar();
|
||||
export const linkItemRoot = style({
|
||||
color: 'inherit',
|
||||
});
|
||||
export const itemRoot = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'left',
|
||||
color: 'inherit',
|
||||
width: '100%',
|
||||
minHeight: '30px',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
position: 'relative',
|
||||
marginTop: '0px',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: cssVar('hoverColor'),
|
||||
},
|
||||
'&[data-active="true"]': {
|
||||
background: cssVar('hoverColor'),
|
||||
},
|
||||
'&[data-disabled="true"]': {
|
||||
cursor: 'default',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&[data-dragging="true"]': {
|
||||
opacity: 0.5,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const itemMain = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 0,
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
gap: 12,
|
||||
});
|
||||
export const itemRenameAnchor = style({
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: -10,
|
||||
width: 10,
|
||||
height: 10,
|
||||
});
|
||||
export const itemContent = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
color: cssVarV2('text/primary'),
|
||||
lineHeight: cssVar('lineHeight'),
|
||||
});
|
||||
export const postfix = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
right: 0,
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
selectors: {
|
||||
[`${itemRoot}:hover &`]: {
|
||||
opacity: 1,
|
||||
pointerEvents: 'initial',
|
||||
position: 'initial',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const iconContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: 20,
|
||||
});
|
||||
export const collapsedIconContainer = style({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '2px',
|
||||
transition: 'transform 0.2s',
|
||||
color: cssVarV2('icon/primary'),
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
'&[data-disabled="true"]': {
|
||||
opacity: 0.3,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:hover': {
|
||||
background: cssVar('hoverColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const collapsedIcon = style({
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapseContentPlaceholder = style({
|
||||
display: 'none',
|
||||
selectors: {
|
||||
'&:only-child': {
|
||||
display: 'initial',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const draggedOverAnimation = keyframes({
|
||||
'0%': {
|
||||
opacity: 1,
|
||||
},
|
||||
'60%': {
|
||||
opacity: 1,
|
||||
},
|
||||
'70%': {
|
||||
opacity: 0,
|
||||
},
|
||||
'80%': {
|
||||
opacity: 1,
|
||||
},
|
||||
'90%': {
|
||||
opacity: 0,
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const contentContainer = style({
|
||||
marginTop: 2,
|
||||
paddingLeft: levelIndent,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const draggingContainer = style({
|
||||
background: cssVar('--affine-background-primary-color'),
|
||||
width: '200px',
|
||||
borderRadius: '6px',
|
||||
});
|
||||
|
||||
export const draggedOverEffect = style({
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&[data-tree-instruction="make-child"][data-self-dragged-over="false"]:after':
|
||||
{
|
||||
display: 'block',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
background: cssVar('--affine-hover-color'),
|
||||
left: levelIndent,
|
||||
top: 0,
|
||||
width: `calc(100% - ${levelIndent})`,
|
||||
height: '100%',
|
||||
},
|
||||
'&[data-tree-instruction="make-child"][data-self-dragged-over="false"][data-open="false"]:after':
|
||||
{
|
||||
animation: `${draggedOverAnimation} 1s infinite linear`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,506 +0,0 @@
|
||||
import {
|
||||
DropIndicator,
|
||||
type DropTargetDropEvent,
|
||||
type DropTargetOptions,
|
||||
type DropTargetTreeInstruction,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
useDraggable,
|
||||
useDropTarget,
|
||||
} from '@affine/component';
|
||||
import { RenameModal } from '@affine/component/rename-modal';
|
||||
import { Guard } from '@affine/core/components/guard';
|
||||
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
|
||||
import type { DocPermissionActions } from '@affine/core/modules/permissions';
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import type { AffineDNDData } from '@affine/core/types/dnd';
|
||||
import { extractEmojiIcon } from '@affine/core/utils';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
ArrowDownSmallIcon,
|
||||
EditIcon,
|
||||
MoreHorizontalIcon,
|
||||
} 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 clsx from 'clsx';
|
||||
import type { To } from 'history';
|
||||
import {
|
||||
Fragment,
|
||||
type RefAttributes,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { ExplorerTreeContext } from './context';
|
||||
import { DropEffect } from './drop-effect';
|
||||
import * as styles from './node.css';
|
||||
import type { NodeOperation } from './types';
|
||||
|
||||
export type ExplorerTreeNodeDropEffectData = {
|
||||
source: { data: AffineDNDData['draggable'] };
|
||||
treeInstruction: DropTargetTreeInstruction | null;
|
||||
};
|
||||
export type ExplorerTreeNodeDropEffect = (
|
||||
data: ExplorerTreeNodeDropEffectData
|
||||
) => 'copy' | 'move' | 'link' | undefined;
|
||||
export type ExplorerTreeNodeIcon = React.ComponentType<{
|
||||
className?: string;
|
||||
draggedOver?: boolean;
|
||||
treeInstruction?: DropTargetTreeInstruction | null;
|
||||
collapsed?: boolean;
|
||||
}>;
|
||||
|
||||
export interface BaseExplorerTreeNodeProps {
|
||||
name?: string;
|
||||
icon?: ExplorerTreeNodeIcon;
|
||||
children?: React.ReactNode;
|
||||
active?: boolean;
|
||||
extractEmojiAsIcon?: boolean;
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
to?: To;
|
||||
postfix?: React.ReactNode;
|
||||
operations?: NodeOperation[];
|
||||
childrenOperations?: NodeOperation[];
|
||||
childrenPlaceholder?: React.ReactNode;
|
||||
|
||||
linkComponent?: React.ComponentType<
|
||||
React.PropsWithChildren<{ to: To; className?: string }> &
|
||||
RefAttributes<any> & { draggable?: boolean }
|
||||
>;
|
||||
[key: `data-${string}`]: any;
|
||||
}
|
||||
|
||||
interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {
|
||||
renameable?: boolean;
|
||||
onRename?: (newName: string) => void;
|
||||
renameableGuard?: { docId: string; action: DocPermissionActions };
|
||||
defaultRenaming?: boolean;
|
||||
|
||||
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
|
||||
reorderable?: boolean;
|
||||
dndData?: AffineDNDData;
|
||||
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
|
||||
dropEffect?: ExplorerTreeNodeDropEffect;
|
||||
}
|
||||
|
||||
/**
|
||||
* specific rename modal for explorer tree node,
|
||||
* Separate it into a separate component to prevent re-rendering the entire component when width changes.
|
||||
*/
|
||||
export const ExplorerTreeNodeRenameModal = ({
|
||||
setRenaming,
|
||||
handleRename,
|
||||
rawName,
|
||||
className,
|
||||
}: {
|
||||
setRenaming: (renaming: boolean) => void;
|
||||
handleRename: (newName: string) => void;
|
||||
rawName: string | undefined;
|
||||
className?: string;
|
||||
}) => {
|
||||
const appSidebarService = useService(AppSidebarService).sidebar;
|
||||
const sidebarWidth = useLiveData(appSidebarService.width$);
|
||||
return (
|
||||
<RenameModal
|
||||
open
|
||||
width={sidebarWidth - 32}
|
||||
onOpenChange={setRenaming}
|
||||
onRename={handleRename}
|
||||
currentName={rawName ?? ''}
|
||||
>
|
||||
<div className={clsx(styles.itemRenameAnchor, className)} />
|
||||
</RenameModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExplorerTreeNode = ({
|
||||
children,
|
||||
icon: Icon,
|
||||
name: rawName,
|
||||
onClick,
|
||||
to,
|
||||
active,
|
||||
defaultRenaming,
|
||||
renameable,
|
||||
renameableGuard,
|
||||
onRename,
|
||||
disabled,
|
||||
collapsed,
|
||||
extractEmojiAsIcon,
|
||||
setCollapsed,
|
||||
canDrop,
|
||||
reorderable = true,
|
||||
operations = [],
|
||||
postfix,
|
||||
childrenOperations = [],
|
||||
childrenPlaceholder,
|
||||
linkComponent: LinkComponent = WorkbenchLink,
|
||||
dndData,
|
||||
onDrop,
|
||||
dropEffect,
|
||||
...otherProps
|
||||
}: WebExplorerTreeNodeProps) => {
|
||||
const t = useI18n();
|
||||
const cid = useId();
|
||||
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 [lastInGroup, setLastInGroup] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { emoji, name } = useMemo(() => {
|
||||
if (!extractEmojiAsIcon || !rawName) {
|
||||
return {
|
||||
emoji: null,
|
||||
name: rawName,
|
||||
};
|
||||
}
|
||||
const { emoji, rest } = extractEmojiIcon(rawName);
|
||||
return {
|
||||
emoji,
|
||||
name: rest,
|
||||
};
|
||||
}, [extractEmojiAsIcon, rawName]);
|
||||
const { dragRef, dragging, CustomDragPreview } = useDraggable<
|
||||
AffineDNDData & { draggable: { __cid: string } }
|
||||
>(
|
||||
() => ({
|
||||
data: { ...dndData?.draggable, __cid: cid },
|
||||
dragPreviewPosition: 'pointer-outside',
|
||||
}),
|
||||
[cid, dndData]
|
||||
);
|
||||
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
|
||||
() => args => {
|
||||
if (!reorderable && args.treeInstruction?.type !== 'make-child') {
|
||||
return false;
|
||||
}
|
||||
return (typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true;
|
||||
},
|
||||
[canDrop, reorderable]
|
||||
);
|
||||
|
||||
const {
|
||||
dropTargetRef,
|
||||
treeInstruction,
|
||||
draggedOverDraggable,
|
||||
draggedOver,
|
||||
draggedOverPosition,
|
||||
} = useDropTarget<AffineDNDData & { draggable: { __cid: string } }>(
|
||||
() => ({
|
||||
data: dndData?.dropTarget,
|
||||
treeInstruction: {
|
||||
currentLevel: level,
|
||||
indentPerLevel: 20,
|
||||
mode: !collapsed
|
||||
? 'expanded'
|
||||
: lastInGroup
|
||||
? 'last-in-group'
|
||||
: 'standard',
|
||||
block:
|
||||
reorderable === false
|
||||
? ['reorder-above', 'reorder-below', 'reparent']
|
||||
: [],
|
||||
},
|
||||
onDrop: data => {
|
||||
if (
|
||||
data.source.data.__cid === cid &&
|
||||
data.treeInstruction?.type !== 'reparent'
|
||||
) {
|
||||
// Do nothing if dropped on self
|
||||
return;
|
||||
}
|
||||
onDrop?.(data);
|
||||
if (data.treeInstruction?.type === 'make-child') {
|
||||
setCollapsed(false);
|
||||
}
|
||||
},
|
||||
canDrop: handleCanDrop,
|
||||
allowExternal: true,
|
||||
}),
|
||||
[
|
||||
dndData?.dropTarget,
|
||||
level,
|
||||
collapsed,
|
||||
lastInGroup,
|
||||
reorderable,
|
||||
handleCanDrop,
|
||||
cid,
|
||||
onDrop,
|
||||
setCollapsed,
|
||||
]
|
||||
);
|
||||
const isSelfDraggedOver = draggedOverDraggable?.data.__cid === cid;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
draggedOver &&
|
||||
treeInstruction?.type === 'make-child' &&
|
||||
!isSelfDraggedOver
|
||||
) {
|
||||
// auto expand when dragged over
|
||||
const timeout = setTimeout(() => {
|
||||
setCollapsed(false);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
return;
|
||||
}, [draggedOver, isSelfDraggedOver, setCollapsed, treeInstruction?.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rootRef.current) {
|
||||
const parent = rootRef.current.parentElement;
|
||||
if (parent) {
|
||||
const updateLastInGroup = () => {
|
||||
setLastInGroup(parent?.lastElementChild === rootRef.current);
|
||||
};
|
||||
updateLastInGroup();
|
||||
const observer = new MutationObserver(updateLastInGroup);
|
||||
observer.observe(parent, {
|
||||
childList: true,
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, []);
|
||||
|
||||
const presetOperations = useMemo(
|
||||
() =>
|
||||
(
|
||||
[
|
||||
renameable
|
||||
? {
|
||||
index: 0,
|
||||
view: renameableGuard ? (
|
||||
<Guard
|
||||
permission={renameableGuard.action}
|
||||
docId={renameableGuard.docId}
|
||||
>
|
||||
{can => (
|
||||
<MenuItem
|
||||
key={'explorer-tree-rename'}
|
||||
type={'default'}
|
||||
prefixIcon={<EditIcon />}
|
||||
onClick={() => setRenaming(true)}
|
||||
disabled={!can}
|
||||
>
|
||||
{t['com.affine.menu.rename']()}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Guard>
|
||||
) : (
|
||||
<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, renameableGuard, t]
|
||||
);
|
||||
|
||||
const { menuOperations, inlineOperations } = useMemo(() => {
|
||||
const sorted = [...presetOperations, ...operations].sort(
|
||||
(a, b) => a.index - b.index
|
||||
);
|
||||
return {
|
||||
menuOperations: sorted.filter(({ inline }) => !inline),
|
||||
inlineOperations: sorted.filter(({ inline }) => !!inline),
|
||||
};
|
||||
}, [presetOperations, operations]);
|
||||
|
||||
const contextValue = useMemo(() => {
|
||||
return {
|
||||
operations: childrenOperations,
|
||||
level: (context?.level ?? 0) + 1,
|
||||
registerChild: () => {
|
||||
setChildCount(c => c + 1);
|
||||
return () => setChildCount(c => c - 1);
|
||||
},
|
||||
};
|
||||
}, [childrenOperations, context?.level]);
|
||||
|
||||
const handleCollapsedChange = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault(); // for links
|
||||
setCollapsed(!collapsed);
|
||||
},
|
||||
[collapsed, setCollapsed]
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(newName: string) => {
|
||||
onRename?.(newName);
|
||||
},
|
||||
[onRename]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (!clickForCollapse) {
|
||||
onClick?.();
|
||||
} else {
|
||||
setCollapsed(!collapsed);
|
||||
}
|
||||
},
|
||||
[clickForCollapse, collapsed, onClick, setCollapsed]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={styles.itemRoot}
|
||||
data-active={active}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
<div
|
||||
data-disabled={disabled}
|
||||
onClick={handleCollapsedChange}
|
||||
data-testid="explorer-collapsed-button"
|
||||
className={styles.collapsedIconContainer}
|
||||
>
|
||||
<ArrowDownSmallIcon
|
||||
className={styles.collapsedIcon}
|
||||
data-collapsed={collapsed !== false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.itemMain}>
|
||||
<div className={styles.iconContainer}>
|
||||
{emoji ??
|
||||
(Icon && (
|
||||
<Icon
|
||||
draggedOver={draggedOver && !isSelfDraggedOver}
|
||||
treeInstruction={treeInstruction}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.itemContent}>{name}</div>
|
||||
{postfix}
|
||||
<div
|
||||
className={styles.postfix}
|
||||
onClick={e => {
|
||||
// prevent jump to page
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{inlineOperations.map(({ view, index }) => (
|
||||
<Fragment key={index}>{view}</Fragment>
|
||||
))}
|
||||
{menuOperations.length > 0 && (
|
||||
<Menu
|
||||
items={menuOperations.map(({ view, index }) => (
|
||||
<Fragment key={index}>{view}</Fragment>
|
||||
))}
|
||||
>
|
||||
<IconButton
|
||||
size="16"
|
||||
data-testid="explorer-tree-node-operation-button"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renameable && renaming && (
|
||||
<ExplorerTreeNodeRenameModal
|
||||
setRenaming={setRenaming}
|
||||
handleRename={handleRename}
|
||||
rawName={rawName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={!collapsed}
|
||||
onOpenChange={setCollapsed}
|
||||
style={assignInlineVars({
|
||||
[styles.levelIndent]: `${level * 20}px`,
|
||||
})}
|
||||
ref={rootRef}
|
||||
{...otherProps}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.contentContainer, styles.draggedOverEffect)}
|
||||
data-open={!collapsed}
|
||||
data-self-dragged-over={isSelfDraggedOver}
|
||||
ref={dropTargetRef}
|
||||
>
|
||||
{to ? (
|
||||
<LinkComponent
|
||||
to={to}
|
||||
className={styles.linkItemRoot}
|
||||
ref={dragRef}
|
||||
draggable={false}
|
||||
>
|
||||
{content}
|
||||
</LinkComponent>
|
||||
) : (
|
||||
<div ref={dragRef}>{content}</div>
|
||||
)}
|
||||
<CustomDragPreview>
|
||||
<div className={styles.draggingContainer}>{content}</div>
|
||||
</CustomDragPreview>
|
||||
{treeInstruction &&
|
||||
// Do not show drop indicator for self dragged over
|
||||
!(treeInstruction.type !== 'reparent' && isSelfDraggedOver) &&
|
||||
treeInstruction.type !== 'instruction-blocked' && (
|
||||
<DropIndicator instruction={treeInstruction} />
|
||||
)}
|
||||
{draggedOver &&
|
||||
dropEffect &&
|
||||
draggedOverPosition &&
|
||||
!isSelfDraggedOver &&
|
||||
draggedOverDraggable && (
|
||||
<DropEffect
|
||||
dropEffect={dropEffect({
|
||||
source: draggedOverDraggable,
|
||||
treeInstruction: treeInstruction,
|
||||
})}
|
||||
position={draggedOverPosition}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Collapsible.Content style={{ display: dragging ? 'none' : undefined }}>
|
||||
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
|
||||
<div className={styles.collapseContentPlaceholder}>
|
||||
{childCount === 0 && !collapsed ? childrenPlaceholder : null}
|
||||
</div>
|
||||
<ExplorerTreeContext.Provider value={contextValue}>
|
||||
{collapsed ? null : children}
|
||||
</ExplorerTreeContext.Provider>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const placeholder = style({
|
||||
display: 'none',
|
||||
selectors: {
|
||||
'&:only-child': {
|
||||
display: 'initial',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ExplorerTreeContext } from './context';
|
||||
import * as styles from './root.css';
|
||||
import type { NodeOperation } from './types';
|
||||
|
||||
export const ExplorerTreeRoot = ({
|
||||
children,
|
||||
childrenOperations = [],
|
||||
placeholder,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
childrenOperations?: NodeOperation[];
|
||||
className?: string;
|
||||
placeholder?: React.ReactNode;
|
||||
}) => {
|
||||
const [childCount, setChildCount] = useState(0);
|
||||
const contextValue = useMemo(() => {
|
||||
return {
|
||||
operations: childrenOperations,
|
||||
level: 0,
|
||||
registerChild: () => {
|
||||
setChildCount(c => c + 1);
|
||||
return () => setChildCount(c => c - 1);
|
||||
},
|
||||
};
|
||||
}, [childrenOperations]);
|
||||
|
||||
return (
|
||||
// <div> is for placeholder:last-child selector
|
||||
<div>
|
||||
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
|
||||
<div className={styles.placeholder}>
|
||||
{childCount === 0 && placeholder}
|
||||
</div>
|
||||
<ExplorerTreeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ExplorerTreeContext.Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export type NodeOperation = {
|
||||
index: number;
|
||||
view: React.ReactNode;
|
||||
inline?: boolean;
|
||||
};
|
||||
@@ -20,7 +20,6 @@ import { configureDocLinksModule } from './doc-link';
|
||||
import { configureDocsSearchModule } from './docs-search';
|
||||
import { configureEditorModule } from './editor';
|
||||
import { configureEditorSettingModule } from './editor-setting';
|
||||
import { configureExplorerModule } from './explorer';
|
||||
import { configureFavoriteModule } from './favorite';
|
||||
import { configureFeatureFlagModule } from './feature-flag';
|
||||
import { configureGlobalContextModule } from './global-context';
|
||||
@@ -32,6 +31,7 @@ import { configureJournalModule } from './journal';
|
||||
import { configureLifecycleModule } from './lifecycle';
|
||||
import { configureMediaModule } from './media';
|
||||
import { configureNavigationModule } from './navigation';
|
||||
import { configureNavigationPanelModule } from './navigation-panel';
|
||||
import { configureNotificationModule } from './notification';
|
||||
import { configureOpenInApp } from './open-in-app';
|
||||
import { configureOrganizeModule } from './organize';
|
||||
@@ -82,7 +82,7 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureDocLinksModule(framework);
|
||||
configureOrganizeModule(framework);
|
||||
configureFavoriteModule(framework);
|
||||
configureExplorerModule(framework);
|
||||
configureNavigationPanelModule(framework);
|
||||
configureThemeEditorModule(framework);
|
||||
configureEditorModule(framework);
|
||||
configureSystemFontFamilyModule(framework);
|
||||
|
||||
@@ -15,7 +15,9 @@ const DEFAULT_COLLAPSABLE_STATE: Record<CollapsibleSectionName, boolean> = {
|
||||
others: false,
|
||||
};
|
||||
|
||||
export class ExplorerSection extends Entity<{ name: CollapsibleSectionName }> {
|
||||
export class NavigationPanelSection extends Entity<{
|
||||
name: CollapsibleSectionName;
|
||||
}> {
|
||||
name: CollapsibleSectionName = this.props.name;
|
||||
key = `explorer.section.${this.name}`;
|
||||
defaultValue = DEFAULT_COLLAPSABLE_STATE[this.name];
|
||||
15
packages/frontend/core/src/modules/navigation-panel/index.ts
Normal file
15
packages/frontend/core/src/modules/navigation-panel/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { GlobalCache } from '../storage';
|
||||
import { WorkspaceScope } from '../workspace';
|
||||
import { NavigationPanelSection } from './entities/navigation-panel-section';
|
||||
import { NavigationPanelService } from './services/navigation-panel';
|
||||
export { NavigationPanelService } from './services/navigation-panel';
|
||||
export type { CollapsibleSectionName } from './types';
|
||||
|
||||
export function configureNavigationPanelModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(NavigationPanelService)
|
||||
.entity(NavigationPanelSection, [GlobalCache]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { ExplorerSection } from '../entities/explore-section';
|
||||
import { NavigationPanelSection } from '../entities/navigation-panel-section';
|
||||
import type { CollapsibleSectionName } from '../types';
|
||||
|
||||
const allSectionName: Array<CollapsibleSectionName> = [
|
||||
@@ -14,12 +14,12 @@ const allSectionName: Array<CollapsibleSectionName> = [
|
||||
'others',
|
||||
];
|
||||
|
||||
export class ExplorerService extends Service {
|
||||
export class NavigationPanelService extends Service {
|
||||
readonly sections = allSectionName.reduce(
|
||||
(prev, name) =>
|
||||
Object.assign(prev, {
|
||||
[name]: this.framework.createEntity(ExplorerSection, { name }),
|
||||
[name]: this.framework.createEntity(NavigationPanelSection, { name }),
|
||||
}),
|
||||
{} as Record<CollapsibleSectionName, ExplorerSection>
|
||||
{} as Record<CollapsibleSectionName, NavigationPanelSection>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user