refactor(core): rename explorer to navigation-panel (#11876)

This commit is contained in:
EYHN
2025-04-22 12:47:35 +08:00
committed by GitHub
parent f918573ba8
commit e4d6833296
104 changed files with 913 additions and 769 deletions

View File

@@ -1,3 +0,0 @@
# explorer
file manager in app left sidebar

View File

@@ -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]);
}

View File

@@ -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'),
},
},
});

View File

@@ -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>
);
};

View File

@@ -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',
},
},
});

View File

@@ -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>
);
});

View File

@@ -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'),
});

View File

@@ -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>
);
});

View File

@@ -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>
);
};

View File

@@ -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>
),
},
]
: []
}
/>
));
};

View File

@@ -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,
]
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
]
);
};

View File

@@ -1,7 +0,0 @@
import { style } from '@vanilla-extract/css';
export const loadingIcon = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});

View File

@@ -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',
},
},
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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',
}}
/>
));
};

View File

@@ -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,
]
);
};

View File

@@ -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',
});

View File

@@ -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;
}

View File

@@ -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}
/>
);
};

View File

@@ -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',
});

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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} />;
};

View File

@@ -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}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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',
});

View File

@@ -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);
};

View File

@@ -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} />;
};

View File

@@ -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>
);
};

View File

@@ -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"
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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)',
});

View File

@@ -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);

View File

@@ -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',
});

View File

@@ -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
);
};

View File

@@ -1,7 +0,0 @@
export { DropEffect } from './drop-effect';
export type {
ExplorerTreeNodeDropEffect,
ExplorerTreeNodeDropEffectData,
} from './node';
export { ExplorerTreeNode } from './node';
export { ExplorerTreeRoot } from './root';

View File

@@ -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`,
},
},
});

View File

@@ -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>
);
};

View File

@@ -1,10 +0,0 @@
import { style } from '@vanilla-extract/css';
export const placeholder = style({
display: 'none',
selectors: {
'&:only-child': {
display: 'initial',
},
},
});

View File

@@ -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>
);
};

View File

@@ -1,5 +0,0 @@
export type NodeOperation = {
index: number;
view: React.ReactNode;
inline?: boolean;
};

View File

@@ -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);

View File

@@ -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];

View 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]);
}

View File

@@ -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>
);
}