refactor(mobile): use separate explorer components for mobile (#8503)

close AF-1488

- remove dnd related logic
- separate styles
- remove empty status, always show a `New` button in each level of tree
This commit is contained in:
CatsJuice
2024-10-22 03:01:04 +00:00
parent 97ccf7f3e4
commit 21d3b5084a
41 changed files with 2814 additions and 251 deletions

View File

@@ -0,0 +1,5 @@
export { CollapsibleSection } from './layouts/collapsible-section';
export { ExplorerCollections } from './sections/collections';
export { ExplorerFavorites } from './sections/favorites';
export { ExplorerOrganize } from './sections/organize';
export { ExplorerTags } from './sections/tags';

View File

@@ -0,0 +1,29 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
import { iconContainer, itemRoot, levelIndent } from '../tree/node.css';
export const wrapper = style([
itemRoot,
{
color: cssVarV2('text/tertiary'),
},
]);
export const root = style({
paddingLeft: levelIndent,
});
export const iconWrapper = style([
iconContainer,
{
color: cssVarV2('text/tertiary'),
fontSize: 24,
},
]);
export const label = style({
fontSize: 17,
fontWeight: 400,
lineHeight: '22px',
letterSpacing: -0.43,
});

View File

@@ -0,0 +1,44 @@
import { ExplorerTreeContext } from '@affine/core/modules/explorer';
import { PlusIcon } from '@blocksuite/icons/rc';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { type HTMLAttributes, useContext } from 'react';
import { levelIndent } from '../tree/node.css';
import * as styles from './add-item-placeholder.css';
export interface AddItemPlaceholderProps
extends HTMLAttributes<HTMLDivElement> {
onClick?: () => void;
label?: string;
icon?: React.ReactNode;
}
export const AddItemPlaceholder = ({
onClick,
label = 'Add Item',
icon = <PlusIcon />,
className,
...attrs
}: AddItemPlaceholderProps) => {
const context = useContext(ExplorerTreeContext);
const level = context?.level ?? 0;
return (
<div
className={styles.root}
style={assignInlineVars({
[levelIndent]: level * 20 + 'px',
})}
>
<div
onClick={onClick}
className={clsx(styles.wrapper, className)}
{...attrs}
>
<div className={styles.iconWrapper}>{icon}</div>
<span className={styles.label}>{label}</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,49 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
// content
export const content = style({
paddingTop: 8,
});
// trigger
export const triggerRoot = style({
fontSize: cssVar('fontXs'),
height: 25,
width: '100%',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
borderRadius: 4,
});
export const triggerLabel = style({
flexGrow: '0',
display: 'flex',
gap: 2,
alignItems: 'center',
justifyContent: 'start',
color: cssVarV2('text/primary'),
fontSize: 20,
lineHeight: '25px',
letterSpacing: -0.45,
fontWeight: 400,
});
export const triggerCollapseIcon = style({
vars: { '--y': '1px', '--r': '90deg' },
color: cssVarV2('icon/tertiary'),
transform: 'translateY(var(--y)) rotate(var(--r))',
transition: 'transform 0.2s',
selectors: {
[`${triggerRoot}[data-collapsed="true"] &`]: {
vars: { '--r': '0deg' },
},
},
});
export const triggerActions = style({
display: 'flex',
gap: 8,
});

View File

@@ -0,0 +1,120 @@
import {
type CollapsibleSectionName,
ExplorerService,
} from '@affine/core/modules/explorer';
import { ToggleCollapseIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type HTMLAttributes,
type ReactNode,
useCallback,
} from 'react';
import {
content,
triggerActions,
triggerCollapseIcon,
triggerLabel,
triggerRoot,
} from './collapsible-section.css';
interface CollapsibleSectionProps extends HTMLAttributes<HTMLDivElement> {
name: CollapsibleSectionName;
title: string;
actions?: ReactNode;
testId?: string;
headerTestId?: string;
headerClassName?: string;
contentClassName?: string;
}
interface CollapsibleSectionTriggerProps
extends HTMLAttributes<HTMLDivElement> {
label: string;
collapsed?: boolean;
actions?: ReactNode;
setCollapsed?: (collapsed: boolean) => void;
}
const CollapsibleSectionTrigger = forwardRef<
HTMLDivElement,
CollapsibleSectionTriggerProps
>(function CollapsibleSectionTrigger(
{ actions, label, collapsed, setCollapsed, className, ...attrs },
ref
) {
const collapsible = collapsed !== undefined;
return (
<div
className={clsx(triggerRoot, className)}
ref={ref}
role="switch"
onClick={() => setCollapsed?.(!collapsed)}
data-collapsed={collapsed}
data-collapsible={collapsible}
{...attrs}
>
<div className={triggerLabel}>
{label}
{collapsible ? (
<ToggleCollapseIcon
width={16}
height={16}
data-testid="category-divider-collapse-button"
className={triggerCollapseIcon}
/>
) : null}
</div>
<div className={triggerActions} onClick={e => e.stopPropagation()}>
{actions}
</div>
</div>
);
});
export const CollapsibleSection = ({
name,
title,
actions,
testId,
headerClassName,
headerTestId,
contentClassName,
children,
...attrs
}: 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}
open={!collapsed}
data-testid={testId}
{...attrs}
>
<CollapsibleSectionTrigger
label={title}
actions={actions}
collapsed={collapsed}
setCollapsed={setCollapsed}
data-testid={headerTestId}
className={headerClassName}
/>
<Collapsible.Content
data-testid="collapsible-section-content"
className={clsx(content, contentClassName)}
>
{children}
</Collapsible.Content>
</Collapsible.Root>
);
};

View File

@@ -0,0 +1,233 @@
import { MenuItem, notify } from '@affine/component';
import {
filterPage,
useEditCollection,
} from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import type { NodeOperation } from '@affine/core/modules/explorer';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import type { Collection } from '@affine/env/filter';
import { PublicPageMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import type { DocMeta } from '@blocksuite/affine/store';
import { FilterMinusIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
import {
DocsService,
GlobalContextService,
LiveData,
useLiveData,
useServices,
} from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { ExplorerTreeNode } from '../../tree/node';
import { ExplorerDocNode } from '../doc';
import {
useExplorerCollectionNodeOperations,
useExplorerCollectionNodeOperationsMenu,
} from './operations';
const CollectionIcon = () => <ViewLayersIcon />;
export const ExplorerCollectionNode = ({
collectionId,
operations: additionalOperations,
}: {
collectionId: string;
operations?: NodeOperation[];
}) => {
const t = useI18n();
const { globalContextService, collectionService } = useServices({
GlobalContextService,
CollectionService,
});
const { open: openEditCollectionModal } = useEditCollection();
const active =
useLiveData(globalContextService.globalContext.collectionId.$) ===
collectionId;
const [collapsed, setCollapsed] = useState(true);
const collection = useLiveData(collectionService.collection$(collectionId));
const handleRename = useCallback(
(name: string) => {
if (collection && collection.name !== name) {
collectionService.updateCollection(collectionId, () => ({
...collection,
name,
}));
track.$.navigationPanel.organize.renameOrganizeItem({
type: 'collection',
});
notify.success({ message: t['com.affine.toastMessage.rename']() });
}
},
[collection, collectionId, collectionService, t]
);
const handleOpenCollapsed = useCallback(() => {
setCollapsed(false);
}, []);
const handleEditCollection = useCallback(() => {
if (!collection) {
return;
}
openEditCollectionModal(collection)
.then(collection => {
return collectionService.updateCollection(
collection.id,
() => collection
);
})
.catch(err => {
console.error(err);
});
}, [collection, collectionService, openEditCollectionModal]);
const collectionOperations = useExplorerCollectionNodeOperationsMenu(
collectionId,
handleOpenCollapsed,
handleEditCollection
);
const { handleAddDocToCollection } = useExplorerCollectionNodeOperations(
collectionId,
handleOpenCollapsed,
handleEditCollection
);
const finalOperations = useMemo(() => {
if (additionalOperations) {
return [...additionalOperations, ...collectionOperations];
}
return collectionOperations;
}, [collectionOperations, additionalOperations]);
if (!collection) {
return null;
}
return (
<ExplorerTreeNode
icon={CollectionIcon}
name={collection.name || t['Untitled']()}
renameable
collapsed={collapsed}
setCollapsed={setCollapsed}
to={`/collection/${collection.id}`}
active={active}
onRename={handleRename}
operations={finalOperations}
data-testid={`explorer-collection-${collectionId}`}
>
<ExplorerCollectionNodeChildren
collection={collection}
onAddDoc={handleAddDocToCollection}
/>
</ExplorerTreeNode>
);
};
const ExplorerCollectionNodeChildren = ({
collection,
onAddDoc,
}: {
collection: Collection;
onAddDoc?: () => void;
}) => {
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);
notify.success({
message: 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 === PublicPageMode.Edgeless
? ('edgeless' as const)
: publicMode === PublicPageMode.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}
operations={
allowList
? [
{
index: 99,
view: (
<MenuItem
prefixIcon={<FilterMinusIcon />}
onClick={() => handleRemoveFromAllowList(doc.id)}
>
{t['Remove special filter']()}
</MenuItem>
),
},
]
: []
}
/>
))}
<AddItemPlaceholder label={t['New Page']()} onClick={onAddDoc} />
</>
);
};

View File

@@ -0,0 +1,264 @@
import {
IconButton,
MenuItem,
MenuSeparator,
useConfirmModal,
} from '@affine/component';
import { usePageHelper } from '@affine/core/components/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 type { NodeOperation } from '@affine/core/modules/explorer';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
DeleteIcon,
FilterIcon,
OpenInNewIcon,
PlusIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import {
FeatureFlagService,
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
export const useExplorerCollectionNodeOperations = (
collectionId: string,
onOpenCollapsed: () => void,
onOpenEdit: () => void
) => {
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(
() => ({
favorite,
handleAddDocToCollection,
handleDeleteCollection,
handleOpenInNewTab,
handleOpenInSplitView,
handleShowEdit,
handleToggleFavoriteCollection,
}),
[
favorite,
handleAddDocToCollection,
handleDeleteCollection,
handleOpenInNewTab,
handleOpenInSplitView,
handleShowEdit,
handleToggleFavoriteCollection,
]
);
};
export const useExplorerCollectionNodeOperationsMenu = (
collectionId: string,
onOpenCollapsed: () => void,
onOpenEdit: () => void
): NodeOperation[] => {
const t = useI18n();
const { featureFlagService } = useServices({ FeatureFlagService });
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const {
favorite,
handleAddDocToCollection,
handleDeleteCollection,
handleOpenInNewTab,
handleOpenInSplitView,
handleShowEdit,
handleToggleFavoriteCollection,
} = useExplorerCollectionNodeOperations(
collectionId,
onOpenCollapsed,
onOpenEdit
);
return useMemo(
() => [
{
index: 0,
inline: true,
view: (
<IconButton
size="16"
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 && enableMultiView
? [
{
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 />}
onClick={handleDeleteCollection}
>
{t['Delete']()}
</MenuItem>
),
},
],
[
enableMultiView,
favorite,
handleAddDocToCollection,
handleDeleteCollection,
handleOpenInNewTab,
handleOpenInSplitView,
handleShowEdit,
handleToggleFavoriteCollection,
t,
]
);
};

View File

@@ -0,0 +1,157 @@
import { Loading } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { DocInfoService } from '@affine/core/modules/doc-info';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import type { NodeOperation } from '@affine/core/modules/explorer';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import {
DocsService,
FeatureFlagService,
GlobalContextService,
LiveData,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { ExplorerTreeNode } from '../../tree/node';
import {
useExplorerDocNodeOperations,
useExplorerDocNodeOperationsMenu,
} from './operations';
import * as styles from './styles.css';
export const ExplorerDocNode = ({
docId,
isLinked,
operations: additionalOperations,
}: {
docId: string;
isLinked?: boolean;
operations?: NodeOperation[];
}) => {
const t = useI18n();
const {
docsSearchService,
docsService,
globalContextService,
docDisplayMetaService,
featureFlagService,
} = useServices({
DocsSearchService,
DocsService,
GlobalContextService,
DocDisplayMetaService,
FeatureFlagService,
});
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 }) => (
<DocIcon className={className} />
),
[DocIcon]
);
const children = useLiveData(
useMemo(
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
[docsSearchService, docId]
)
);
const indexerLoading = useLiveData(
docsSearchService.indexer.status$.map(
v => v.remaining === undefined || v.remaining > 0
)
);
const [referencesLoading, setReferencesLoading] = useState(true);
useLayoutEffect(() => {
setReferencesLoading(
prev =>
prev &&
indexerLoading /* after loading becomes false, it never becomes true */
);
}, [indexerLoading]);
const handleRename = useAsyncCallback(
async (newName: string) => {
await docsService.changeDocTitle(docId, newName);
track.$.navigationPanel.organize.renameOrganizeItem({ type: 'doc' });
},
[docId, docsService]
);
const docInfoModal = useService(DocInfoService).modal;
const option = useMemo(
() => ({
openInfoModal: () => docInfoModal.open(docId),
openNodeCollapsed: () => setCollapsed(false),
}),
[docId, docInfoModal]
);
const operations = useExplorerDocNodeOperationsMenu(docId, option);
const { handleAddLinkedPage } = useExplorerDocNodeOperations(docId, option);
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)}
renameable
extractEmojiAsIcon={enableEmojiIcon}
collapsed={collapsed}
setCollapsed={setCollapsed}
to={`/${docId}`}
active={active}
postfix={
referencesLoading &&
!collapsed && (
<div className={styles.loadingIcon}>
<Loading />
</div>
)
}
onRename={handleRename}
operations={finalOperations}
data-testid={`explorer-doc-${docId}`}
>
{children?.map(child => (
<ExplorerDocNode key={child.docId} docId={child.docId} isLinked />
))}
<AddItemPlaceholder
label={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
onClick={handleAddLinkedPage}
/>
</ExplorerTreeNode>
);
};

View File

@@ -0,0 +1,296 @@
import {
IconButton,
MenuItem,
MenuSeparator,
toast,
useConfirmModal,
} from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
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 type { NodeOperation } from '@affine/core/modules/explorer';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
DeleteIcon,
DuplicateIcon,
InformationIcon,
LinkedPageIcon,
OpenInNewIcon,
PlusIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import {
DocsService,
FeatureFlagService,
useLiveData,
useService,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
export const useExplorerDocNodeOperations = (
docId: string,
options: {
openInfoModal: () => void;
openNodeCollapsed: () => void;
}
) => {
const t = useI18n();
const {
workbenchService,
workspaceService,
docsService,
compatibleFavoriteItemsAdapter,
} = useServices({
DocsService,
WorkbenchService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
});
const { openConfirmModal } = useConfirmModal();
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 () => {
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();
}, [createPage, docsService, docId, options]);
const handleToggleFavoriteDoc = useCallback(() => {
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');
track.$.navigationPanel.organize.toggleFavorite({
type: 'doc',
});
}, [docId, compatibleFavoriteItemsAdapter]);
return useMemo(
() => ({
favorite,
handleAddLinkedPage,
handleDuplicate,
handleToggleFavoriteDoc,
handleOpenInSplitView,
handleOpenInNewTab,
handleMoveToTrash,
handleOpenInfoModal,
}),
[
favorite,
handleAddLinkedPage,
handleDuplicate,
handleMoveToTrash,
handleOpenInNewTab,
handleOpenInSplitView,
handleOpenInfoModal,
handleToggleFavoriteDoc,
]
);
};
export const useExplorerDocNodeOperationsMenu = (
docId: string,
options: {
openInfoModal: () => void;
openNodeCollapsed: () => void;
}
): NodeOperation[] => {
const t = useI18n();
const featureFlagService = useService(FeatureFlagService);
const {
favorite,
handleAddLinkedPage,
handleDuplicate,
handleToggleFavoriteDoc,
handleOpenInSplitView,
handleOpenInNewTab,
handleMoveToTrash,
handleOpenInfoModal,
} = useExplorerDocNodeOperations(docId, options);
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
return useMemo(
() => [
{
index: 0,
inline: true,
view: (
<IconButton
size="16"
icon={<PlusIcon />}
tooltip={t['com.affine.rootAppSidebar.explorer.doc-add-tooltip']()}
onClick={handleAddLinkedPage}
/>
),
},
{
index: 50,
view: (
<MenuItem
prefixIcon={<InformationIcon />}
onClick={handleOpenInfoModal}
>
{t['com.affine.page-properties.page-info.view']()}
</MenuItem>
),
},
{
index: 99,
view: (
<MenuItem
prefixIcon={<LinkedPageIcon />}
onClick={handleAddLinkedPage}
>
{t['com.affine.page-operation.add-linked-page']()}
</MenuItem>
),
},
{
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 && enableMultiView
? [
{
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: (
<MenuItem
type={'danger'}
prefixIcon={<DeleteIcon />}
onClick={handleMoveToTrash}
>
{t['com.affine.moveToTrash.title']()}
</MenuItem>
),
},
],
[
enableMultiView,
favorite,
handleAddLinkedPage,
handleDuplicate,
handleMoveToTrash,
handleOpenInNewTab,
handleOpenInSplitView,
handleOpenInfoModal,
handleToggleFavoriteDoc,
t,
]
);
};

View File

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

View File

@@ -0,0 +1,407 @@
import {
AnimatedFolderIcon,
IconButton,
MenuItem,
MenuSeparator,
MenuSub,
notify,
} from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import {
useSelectCollection,
useSelectDoc,
useSelectTag,
} from '@affine/core/components/page-list/selector';
import type {
ExplorerTreeNodeIcon,
NodeOperation,
} from '@affine/core/modules/explorer';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import {
type FolderNode,
OrganizeService,
} from '@affine/core/modules/organize';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import {
DeleteIcon,
FolderIcon,
LayerIcon,
PageIcon,
PlusIcon,
PlusThickIcon,
RemoveFolderIcon,
TagsIcon,
} from '@blocksuite/icons/rc';
import {
FeatureFlagService,
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { difference } from 'lodash-es';
import { useCallback, useMemo, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { ExplorerTreeNode } from '../../tree/node';
import { ExplorerCollectionNode } from '../collection';
import { ExplorerDocNode } from '../doc';
import { ExplorerTagNode } from '../tag';
import { FavoriteFolderOperation } from './operations';
export const ExplorerFolderNode = ({
nodeId,
defaultRenaming,
operations,
}: {
defaultRenaming?: boolean;
nodeId: string;
operations?:
| NodeOperation[]
| ((type: string, node: FolderNode) => NodeOperation[]);
}) => {
const { organizeService } = useServices({
OrganizeService,
});
const node = useLiveData(organizeService.folderTree.folderNode$(nodeId));
const type = useLiveData(node?.type$);
const data = useLiveData(node?.data$);
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}
defaultRenaming={defaultRenaming}
operations={additionalOperations}
/>
);
}
if (!data) return null;
if (type === 'doc') {
return <ExplorerDocNode docId={data} operations={additionalOperations} />;
} else if (type === 'collection') {
return (
<ExplorerCollectionNode
collectionId={data}
operations={additionalOperations}
/>
);
} else if (type === 'tag') {
return <ExplorerTagNode tagId={data} operations={additionalOperations} />;
}
return;
};
const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
collapsed,
className,
draggedOver,
treeInstruction,
}) => (
<AnimatedFolderIcon
className={className}
open={
!collapsed || (!!draggedOver && treeInstruction?.type === 'make-child')
}
/>
);
const ExplorerFolderNodeFolder = ({
node,
defaultRenaming,
operations: additionalOperations,
}: {
defaultRenaming?: boolean;
node: FolderNode;
operations?: NodeOperation[];
}) => {
const t = useI18n();
const { workspaceService, featureFlagService } = useServices({
WorkspaceService,
CompatibleFavoriteItemsAdapter,
FeatureFlagService,
});
const openDocsSelector = useSelectDoc();
const openTagsSelector = useSelectTag();
const openCollectionsSelector = useSelectCollection();
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 handleRename = useCallback(
(newName: string) => {
node.rename(newName);
},
[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'
? openDocsSelector
: type === 'collection'
? openCollectionsSelector
: openTagsSelector;
selector(initialIds)
.then(selectedIds => {
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);
})
.catch(err => {
console.error(`Unexpected error while selecting ${type}`, err);
});
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: type,
});
},
[
children,
node,
openCollectionsSelector,
openDocsSelector,
openTagsSelector,
]
);
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={<LayerIcon />}
>
{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(
// eslint-disable-next-line @typescript-eslint/ban-types
(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}
defaultRenaming={defaultRenaming}
renameable
extractEmojiAsIcon={enableEmojiIcon}
collapsed={collapsed}
setCollapsed={handleCollapsedChange}
onRename={handleRename}
operations={finalOperations}
data-testid={`explorer-folder-${node.id}`}
>
{children.map(child => (
<ExplorerFolderNode
key={child.id}
nodeId={child.id as string}
defaultRenaming={child.id === newFolderId}
operations={childrenOperations}
/>
))}
<AddItemPlaceholder
label={t['com.affine.rootAppSidebar.organize.folder.add-docs']()}
onClick={() => handleAddToFolder('doc')}
/>
</ExplorerTreeNode>
);
};

View File

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,135 @@
import type { NodeOperation } from '@affine/core/modules/explorer';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import {
GlobalContextService,
useLiveData,
useServices,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useMemo, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { ExplorerTreeNode } from '../../tree/node';
import { ExplorerDocNode } from '../doc';
import {
useExplorerTagNodeOperations,
useExplorerTagNodeOperationsMenu,
} from './operations';
import * as styles from './styles.css';
export const ExplorerTagNode = ({
tagId,
operations: additionalOperations,
defaultRenaming,
}: {
tagId: string;
defaultRenaming?: boolean;
operations?: NodeOperation[];
}) => {
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 handleRename = useCallback(
(newName: string) => {
if (tagRecord && tagRecord.value$.value !== newName) {
tagRecord.rename(newName);
track.$.navigationPanel.organize.renameOrganizeItem({
type: 'tag',
});
}
},
[tagRecord]
);
const option = useMemo(
() => ({
openNodeCollapsed: () => setCollapsed(false),
}),
[]
);
const operations = useExplorerTagNodeOperationsMenu(tagId, option);
const { handleNewDoc } = useExplorerTagNodeOperations(tagId, option);
const finalOperations = useMemo(() => {
if (additionalOperations) {
return [...operations, ...additionalOperations];
}
return operations;
}, [additionalOperations, operations]);
if (!tagRecord) {
return null;
}
return (
<ExplorerTreeNode
icon={Icon}
name={tagName || t['Untitled']()}
renameable
collapsed={collapsed}
setCollapsed={setCollapsed}
to={`/tag/${tagId}`}
active={active}
defaultRenaming={defaultRenaming}
onRename={handleRename}
operations={finalOperations}
data-testid={`explorer-tag-${tagId}`}
>
<ExplorerTagNodeDocs tag={tagRecord} onNewDoc={handleNewDoc} />
</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,
onNewDoc,
}: {
tag: Tag;
onNewDoc?: () => void;
}) => {
const t = useI18n();
const tagDocIds = useLiveData(tag.pageIds$);
return (
<>
{tagDocIds.map(docId => (
<ExplorerDocNode key={docId} docId={docId} />
))}
<AddItemPlaceholder label={t['New Page']()} onClick={onNewDoc} />
</>
);
};

View File

@@ -0,0 +1,207 @@
import { IconButton, MenuItem, MenuSeparator, toast } from '@affine/component';
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import type { NodeOperation } from '@affine/core/modules/explorer';
import { FavoriteService } from '@affine/core/modules/favorite';
import { TagService } from '@affine/core/modules/tag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
DeleteIcon,
OpenInNewIcon,
PlusIcon,
SplitViewIcon,
} from '@blocksuite/icons/rc';
import {
DocsService,
FeatureFlagService,
useLiveData,
useService,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
export const useExplorerTagNodeOperations = (
tagId: string,
{
openNodeCollapsed,
}: {
openNodeCollapsed: () => void;
}
) => {
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(
() => ({
favorite,
handleNewDoc,
handleMoveToTrash,
handleOpenInSplitView,
handleToggleFavoriteTag,
handleOpenInNewTab,
}),
[
favorite,
handleMoveToTrash,
handleNewDoc,
handleOpenInNewTab,
handleOpenInSplitView,
handleToggleFavoriteTag,
]
);
};
export const useExplorerTagNodeOperationsMenu = (
tagId: string,
option: {
openNodeCollapsed: () => void;
}
): NodeOperation[] => {
const t = useI18n();
const featureFlagService = useService(FeatureFlagService);
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const {
favorite,
handleNewDoc,
handleMoveToTrash,
handleOpenInSplitView,
handleToggleFavoriteTag,
handleOpenInNewTab,
} = useExplorerTagNodeOperations(tagId, option);
return useMemo(
() => [
{
index: 0,
inline: true,
view: (
<IconButton
size="16"
onClick={handleNewDoc}
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 && enableMultiView
? [
{
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}
>
{t['Delete']()}
</MenuItem>
),
},
],
[
enableMultiView,
favorite,
handleMoveToTrash,
handleNewDoc,
handleOpenInNewTab,
handleOpenInSplitView,
handleToggleFavoriteTag,
t,
]
);
};

View File

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,73 @@
import { useEditCollectionName } from '@affine/core/components/page-list';
import { createEmptyCollection } from '@affine/core/components/page-list/use-collection-manager';
import { CollectionService } from '@affine/core/modules/collection';
import { ExplorerService } from '@affine/core/modules/explorer';
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 { useLiveData, useServices } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerCollectionNode } from '../../nodes/collection';
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 { open: openCreateCollectionModel } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
const handleCreateCollection = useCallback(() => {
openCreateCollectionModel('')
.then(name => {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
track.$.navigationPanel.organize.createOrganizeItem({
type: 'collection',
});
workbenchService.workbench.openCollection(id);
explorerSection.setCollapsed(false);
})
.catch(err => {
console.error(err);
});
}, [
collectionService,
explorerSection,
openCreateCollectionModel,
workbenchService.workbench,
]);
return (
<CollapsibleSection
name="collections"
testId="explorer-collections"
title={t['com.affine.rootAppSidebar.collections']()}
>
<ExplorerTreeRoot>
{collections.map(collection => (
<ExplorerCollectionNode
key={collection.id}
collectionId={collection.id}
/>
))}
<AddItemPlaceholder
data-testid="explorer-bar-add-collection-button"
label={t['com.affine.rootAppSidebar.collection.new']()}
onClick={handleCreateCollection}
/>
</ExplorerTreeRoot>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,88 @@
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import {
ExplorerService,
ExplorerTreeRoot,
} from '@affine/core/modules/explorer';
import type { FavoriteSupportType } from '@affine/core/modules/favorite';
import { FavoriteService } from '@affine/core/modules/favorite';
import { useI18n } from '@affine/i18n';
import {
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
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';
export const ExplorerFavorites = () => {
const { favoriteService, workspaceService, explorerService } = useServices({
FavoriteService,
WorkspaceService,
ExplorerService,
});
const t = useI18n();
const explorerSection = explorerService.sections.favorites;
const favorites = useLiveData(favoriteService.favoriteList.sortedList$);
const isLoading = useLiveData(favoriteService.favoriteList.isLoading$);
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const handleCreateNewFavoriteDoc = useCallback(() => {
const newDoc = createPage();
favoriteService.favoriteList.add(
'doc',
newDoc.id,
favoriteService.favoriteList.indexAt('before')
);
explorerSection.setCollapsed(false);
}, [createPage, explorerSection, favoriteService.favoriteList]);
return (
<CollapsibleSection
name="favorites"
title={t['com.affine.rootAppSidebar.favorites']()}
testId="explorer-favorites"
headerTestId="explorer-favorite-category-divider"
>
<ExplorerTreeRoot placeholder={isLoading ? 'Loading' : null}>
{favorites.map(favorite => (
<FavoriteNode key={favorite.id} favorite={favorite} />
))}
<AddItemPlaceholder
data-testid="explorer-bar-add-favorite-button"
data-event-props="$.navigationPanel.favorites.createDoc"
data-event-args-control="addFavorite"
onClick={handleCreateNewFavoriteDoc}
label={t['New Page']()}
/>
</ExplorerTreeRoot>
</CollapsibleSection>
);
};
export const FavoriteNode = ({
favorite,
}: {
favorite: {
id: string;
type: FavoriteSupportType;
};
}) => {
return favorite.type === 'doc' ? (
<ExplorerDocNode docId={favorite.id} />
) : favorite.type === 'tag' ? (
<ExplorerTagNode tagId={favorite.id} />
) : favorite.type === 'folder' ? (
<ExplorerFolderNode nodeId={favorite.id} />
) : (
<ExplorerCollectionNode collectionId={favorite.id} />
);
};

View File

@@ -0,0 +1,6 @@
import { Skeleton } from '@affine/component';
export const MobileFavoritesLoading = () => {
// TODO(@CatsJuice): loading UI
return <Skeleton />;
};

View File

@@ -0,0 +1,70 @@
import { Skeleton } from '@affine/component';
import {
ExplorerService,
ExplorerTreeRoot,
} from '@affine/core/modules/explorer';
import { OrganizeService } from '@affine/core/modules/organize';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerFolderNode } from '../../nodes/folder';
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]);
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']()}
>
{/* TODO(@CatsJuice): Organize loading UI */}
<ExplorerTreeRoot placeholder={isLoading ? <Skeleton /> : null}>
{folders.map(child => (
<ExplorerFolderNode
key={child.id}
nodeId={child.id as string}
defaultRenaming={child.id === newFolderId}
/>
))}
<AddItemPlaceholder
data-testid="explorer-bar-add-organize-button"
label={t['com.affine.rootAppSidebar.organize.add-folder']()}
onClick={handleCreateFolder}
/>
</ExplorerTreeRoot>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,63 @@
import { ExplorerService } from '@affine/core/modules/explorer';
import { ExplorerTreeRoot } from '@affine/core/modules/explorer/views/tree';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { ExplorerTagNode } from '../../nodes/tag';
export const ExplorerTags = () => {
const { tagService, explorerService } = useServices({
TagService,
ExplorerService,
});
const explorerSection = explorerService.sections.tags;
const collapsed = useLiveData(explorerSection.collapsed$);
const [createdTag, setCreatedTag] = useState<Tag | null>(null);
const tags = useLiveData(tagService.tagList.tags$);
const t = useI18n();
const handleCreateNewFavoriteDoc = useCallback(() => {
const newTags = tagService.tagList.createTag(
t['com.affine.rootAppSidebar.tags.new-tag'](),
tagService.randomTagColor()
);
setCreatedTag(newTags);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
explorerSection.setCollapsed(false);
}, [explorerSection, t, tagService]);
useEffect(() => {
if (collapsed) setCreatedTag(null); // reset created tag to clear the renaming state
}, [collapsed]);
return (
<CollapsibleSection
name="tags"
title={t['com.affine.rootAppSidebar.tags']()}
>
<ExplorerTreeRoot>
{tags.map(tag => (
<ExplorerTagNode
key={tag.id}
tagId={tag.id}
defaultRenaming={createdTag?.id === tag.id}
/>
))}
<AddItemPlaceholder
data-testid="explorer-bar-add-favorite-button"
onClick={handleCreateNewFavoriteDoc}
label={t[
'com.affine.rootAppSidebar.explorer.tag-section-add-tooltip'
]()}
/>
</ExplorerTreeRoot>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,133 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
export const levelIndent = createVar();
export const itemRoot = style({
display: 'inline-flex',
alignItems: 'center',
textAlign: 'left',
color: 'inherit',
width: '100%',
minHeight: '30px',
userSelect: 'none',
cursor: 'pointer',
fontSize: cssVar('fontSm'),
position: 'relative',
marginTop: '0px',
padding: '8px',
borderRadius: 0,
gap: 12,
selectors: {
'&[data-disabled="true"]': {
cursor: 'default',
color: cssVar('textSecondaryColor'),
pointerEvents: 'none',
},
'&[data-dragging="true"]': {
opacity: 0.5,
},
},
':after': {
content: '',
width: `calc(100% + ${levelIndent})`,
height: 0.5,
background: cssVar('borderColor'),
bottom: 0,
position: 'absolute',
right: 0,
},
});
export const collapsedIconContainer = style({
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px',
transition: 'transform 0.2s',
color: cssVarV2('icon/primary'),
fontSize: 16,
selectors: {
'&[data-collapsed="true"]': {
transform: 'rotate(-90deg)',
},
'&[data-disabled="true"]': {
opacity: 0.3,
pointerEvents: 'none',
},
},
});
export const collapsedIcon = style({
transition: 'transform 0.2s ease-in-out',
selectors: {
'&[data-collapsed="true"]': {
transform: 'rotate(-90deg)',
},
},
});
export const itemMain = style({
display: 'flex',
alignItems: 'center',
width: 0,
flex: 1,
position: 'relative',
gap: 12,
});
export const iconContainer = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: cssVarV2('icon/primary'),
width: 32,
height: 32,
fontSize: 24,
});
export const itemContent = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
alignItems: 'center',
flex: 1,
color: cssVarV2('text/primary'),
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
fontWeight: 400,
});
export const itemRenameAnchor = style({
pointerEvents: 'none',
position: 'absolute',
left: 0,
top: -10,
width: 10,
height: 10,
});
export const contentContainer = style({
marginTop: 0,
paddingLeft: levelIndent,
position: 'relative',
});
export const linkItemRoot = style({
color: 'inherit',
});
export const collapseContentPlaceholder = style({
display: 'none',
selectors: {
'&:only-child': {
display: 'initial',
},
},
});

View File

@@ -0,0 +1,241 @@
import { MenuItem, MobileMenu } from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal';
import { AppSidebarService } from '@affine/core/modules/app-sidebar';
import type {
BaseExplorerTreeNodeProps,
NodeOperation,
} from '@affine/core/modules/explorer';
import { ExplorerTreeContext } from '@affine/core/modules/explorer';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { extractEmojiIcon } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { ArrowDownSmallIcon, EditIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import {
Fragment,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
import * as styles from './node.css';
interface ExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {}
export const ExplorerTreeNode = ({
children,
icon: Icon,
name: rawName,
onClick,
to,
active,
defaultRenaming,
renameable,
onRename,
disabled,
collapsed,
extractEmojiAsIcon,
setCollapsed,
operations = [],
postfix,
childrenOperations = [],
childrenPlaceholder,
linkComponent: LinkComponent = WorkbenchLink,
...otherProps
}: ExplorerTreeNodeProps) => {
const t = useI18n();
const context = useContext(ExplorerTreeContext);
const level = context?.level ?? 0;
// If no onClick or to is provided, clicking on the node will toggle the collapse state
const clickForCollapse = !onClick && !to && !disabled;
const [childCount, setChildCount] = useState(0);
const [renaming, setRenaming] = useState(defaultRenaming);
const rootRef = useRef<HTMLDivElement>(null);
const appSidebarService = useService(AppSidebarService).sidebar;
const sidebarWidth = useLiveData(appSidebarService.width$);
const { emoji, name } = useMemo(() => {
if (!extractEmojiAsIcon || !rawName) {
return {
emoji: null,
name: rawName,
};
}
const { emoji, rest } = extractEmojiIcon(rawName);
return {
emoji,
name: rest,
};
}, [extractEmojiAsIcon, rawName]);
const presetOperations = useMemo(
() =>
(
[
renameable
? {
index: 0,
view: (
<MenuItem
key={'explorer-tree-rename'}
type={'default'}
prefixIcon={<EditIcon />}
onClick={() => setRenaming(true)}
>
{t['com.affine.menu.rename']()}
</MenuItem>
),
}
: null,
] as (NodeOperation | null)[]
).filter((t): t is NodeOperation => t !== null),
[renameable, t]
);
const { menuOperations } = useMemo(() => {
const sorted = [...presetOperations, ...operations].sort(
(a, b) => a.index - b.index
);
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 className={styles.itemMain}>
{menuOperations.length > 0 ? (
<div
onClick={e => {
// prevent jump to page
e.preventDefault();
}}
>
<MobileMenu
items={menuOperations.map(({ view }, index) => (
<Fragment key={index}>{view}</Fragment>
))}
>
<div className={styles.iconContainer}>
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
</div>
</MobileMenu>
</div>
) : (
<div className={styles.iconContainer}>
{emoji ?? (Icon && <Icon collapsed={collapsed} />)}
</div>
)}
<div className={styles.itemContent}>{name}</div>
{postfix}
</div>
<div
data-disabled={disabled}
onClick={handleCollapsedChange}
data-testid="explorer-collapsed-button"
className={styles.collapsedIconContainer}
>
<ArrowDownSmallIcon
className={styles.collapsedIcon}
data-collapsed={collapsed !== false}
/>
</div>
{renameable && (
<RenameModal
open={!!renaming}
width={sidebarWidth - 32}
onOpenChange={setRenaming}
onRename={handleRename}
currentName={rawName ?? ''}
>
<div className={styles.itemRenameAnchor} />
</RenameModal>
)}
</div>
);
return (
<Collapsible.Root
open={!collapsed}
onOpenChange={setCollapsed}
style={assignInlineVars({
[styles.levelIndent]: `${level * 20}px`,
})}
ref={rootRef}
{...otherProps}
>
<div className={styles.contentContainer} data-open={!collapsed}>
{to ? (
<LinkComponent to={to} className={styles.linkItemRoot}>
{content}
</LinkComponent>
) : (
<div>{content}</div>
)}
</div>
<Collapsible.Content>
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
<div className={styles.collapseContentPlaceholder}>
{childCount === 0 && !collapsed && childrenPlaceholder}
</div>
<ExplorerTreeContext.Provider value={contextValue}>
{collapsed ? null : children}
</ExplorerTreeContext.Provider>
</Collapsible.Content>
</Collapsible.Root>
);
};

View File

@@ -84,6 +84,7 @@ export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
style={{ padding: 10 }}
onClick={handleRouteBack}
icon={<ArrowLeftSmallIcon />}
data-testid="page-header-back"
/>
) : null}
{prefix}

View File

@@ -1,21 +1,19 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../components';
import {
ExplorerCollections,
ExplorerFavorites,
ExplorerMigrationFavorites,
ExplorerMobileContext,
ExplorerOrganize,
} from '@affine/core/modules/explorer';
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
import { AppTabs } from '../../components';
ExplorerTags,
} from '../../components/explorer';
import { HomeHeader, RecentDocs } from '../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
return (
<ExplorerMobileContext.Provider value={true}>
<>
<HomeHeader />
<RecentDocs />
<SafeArea bottom>
@@ -29,12 +27,11 @@ export const Component = () => {
>
<ExplorerFavorites />
<ExplorerOrganize />
<ExplorerMigrationFavorites />
<ExplorerCollections />
<ExplorerTags />
</div>
</SafeArea>
<AppTabs />
</ExplorerMobileContext.Provider>
</>
);
};

View File

@@ -1,9 +1,9 @@
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { CollapsibleSection } from '@affine/core/modules/explorer';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useMemo } from 'react';
import { DocCard } from '../../components/doc-card';
import { CollapsibleSection } from '../../components/explorer';
import * as styles from './styles.css';
export const RecentDocs = ({ max = 5 }: { max?: number }) => {

View File

@@ -64,27 +64,3 @@ export const collapseIcon = style({
},
},
});
// ------------- mobile -------------
export const mobileRoot = style([
root,
{
height: 25,
padding: '0 16px',
selectors: {
'&[data-collapsible="true"]:hover': {
backgroundColor: 'none',
},
},
},
]);
export const mobileLabel = style([
label,
{
color: cssVarV2('text/primary'),
fontSize: 20,
lineHeight: '25px',
letterSpacing: -0.45,
fontWeight: 400,
},
]);

View File

@@ -9,7 +9,6 @@ export type CategoryDividerProps = PropsWithChildren<
label: string;
className?: string;
collapsed?: boolean;
mobile?: boolean;
setCollapsed?: (collapsed: boolean) => void;
} & {
[key: `data-${string}`]: unknown;
@@ -23,7 +22,6 @@ export const CategoryDivider = forwardRef(
children,
className,
collapsed,
mobile,
setCollapsed,
...otherProps
}: CategoryDividerProps,
@@ -33,16 +31,15 @@ export const CategoryDivider = forwardRef(
return (
<div
className={clsx(mobile ? styles.mobileRoot : styles.root, className)}
className={clsx(styles.root, className)}
ref={ref}
role="switch"
onClick={() => setCollapsed?.(!collapsed)}
data-mobile={mobile}
data-collapsed={collapsed}
data-collapsible={collapsible}
{...otherProps}
>
<div className={mobile ? styles.mobileLabel : styles.label}>
<div className={styles.label}>
{label}
{collapsible ? (
<ToggleCollapseIcon
@@ -53,11 +50,9 @@ export const CategoryDivider = forwardRef(
/>
) : null}
</div>
{mobile ? null : (
<div className={styles.actions} onClick={e => e.stopPropagation()}>
{children}
</div>
)}
<div className={styles.actions} onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
);
}

View File

@@ -9,11 +9,18 @@ import { ExplorerService } from './services/explorer';
export { ExplorerService } from './services/explorer';
export type { CollapsibleSectionName } from './types';
export { CollapsibleSection } from './views/layouts/collapsible-section';
export { ExplorerMobileContext } from './views/mobile.context';
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

View File

@@ -13,8 +13,3 @@ export const header = style({
},
},
});
// mobile
export const mobileContent = style({
paddingTop: 8,
});

View File

@@ -7,18 +7,11 @@ import {
type ReactNode,
type RefObject,
useCallback,
useContext,
} from 'react';
import { ExplorerService } from '../../services/explorer';
import type { CollapsibleSectionName } from '../../types';
import { ExplorerMobileContext } from '../mobile.context';
import {
content,
header,
mobileContent,
root,
} from './collapsible-section.css';
import { content, header, root } from './collapsible-section.css';
interface CollapsibleSectionProps extends PropsWithChildren {
name: CollapsibleSectionName;
@@ -50,7 +43,6 @@ export const CollapsibleSection = ({
contentClassName,
}: CollapsibleSectionProps) => {
const mobile = useContext(ExplorerMobileContext);
const section = useService(ExplorerService).sections[name];
const collapsed = useLiveData(section.collapsed$);
@@ -70,7 +62,6 @@ export const CollapsibleSection = ({
data-testid={testId}
>
<CategoryDivider
mobile={mobile}
data-testid={headerTestId}
label={title}
setCollapsed={setCollapsed}
@@ -82,7 +73,7 @@ export const CollapsibleSection = ({
</CategoryDivider>
<Collapsible.Content
data-testid="collapsible-section-content"
className={clsx(mobile ? mobileContent : content, contentClassName)}
className={clsx(content, contentClassName)}
>
{children}
</Collapsible.Content>

View File

@@ -1,8 +0,0 @@
import { createContext } from 'react';
/**
* To enable mobile manually
* > Using `environment.isMobile` directly will affect current web entry on mobile
* > So we control it manually for now
*/
export const ExplorerMobileContext = createContext(false);

View File

@@ -73,15 +73,8 @@ export const useExplorerCollectionNodeOperations = (
track.$.navigationPanel.collections.addDocToCollection({
control: 'button',
});
workbenchService.workbench.openDoc(newDoc.id);
onOpenCollapsed();
}, [
collectionId,
collectionService,
createPage,
onOpenCollapsed,
workbenchService.workbench,
]);
}, [collectionId, collectionService, createPage, onOpenCollapsed]);
const handleToggleFavoriteCollection = useCallback(() => {
compatibleFavoriteItemsAdapter.toggle(collectionId, 'collection');

View File

@@ -129,9 +129,8 @@ export const useExplorerDocNodeOperations = (
await docsService.addLinkedDoc(docId, newDoc.id);
track.$.navigationPanel.docs.createDoc({ control: 'linkDoc' });
track.$.navigationPanel.docs.linkDoc({ control: 'createDoc' });
workbenchService.workbench.openDoc(newDoc.id);
options.openNodeCollapsed();
}, [createPage, docsService, docId, workbenchService.workbench, options]);
}, [createPage, docsService, docId, options]);
const handleToggleFavoriteDoc = useCallback(() => {
compatibleFavoriteItemsAdapter.toggle(docId, 'doc');

View File

@@ -20,7 +20,6 @@ import {
type FolderNode,
OrganizeService,
} from '@affine/core/modules/organize';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { Unreachable } from '@affine/env/constant';
import { useI18n } from '@affine/i18n';
@@ -173,7 +172,7 @@ const ExplorerFolderIcon: ExplorerTreeNodeIcon = ({
/>
);
export const ExplorerFolderNodeFolder = ({
const ExplorerFolderNodeFolder = ({
node,
onDrop,
defaultRenaming,
@@ -187,13 +186,11 @@ export const ExplorerFolderNodeFolder = ({
node: FolderNode;
} & GenericExplorerNode) => {
const t = useI18n();
const { workbenchService, workspaceService, featureFlagService } =
useServices({
WorkbenchService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
FeatureFlagService,
});
const { workspaceService, featureFlagService } = useServices({
WorkspaceService,
CompatibleFavoriteItemsAdapter,
FeatureFlagService,
});
const openDocsSelector = useSelectDoc();
const openTagsSelector = useSelectTag();
const openCollectionsSelector = useSelectCollection();
@@ -552,14 +549,13 @@ export const ExplorerFolderNodeFolder = ({
const handleNewDoc = useCallback(() => {
const newDoc = createPage();
node.createLink('doc', newDoc.id, node.indexAt('before'));
workbenchService.workbench.openDoc(newDoc.id);
track.$.navigationPanel.folders.createDoc();
track.$.navigationPanel.organize.createOrganizeItem({
type: 'link',
target: 'doc',
});
setCollapsed(false);
}, [createPage, node, workbenchService.workbench]);
}, [createPage, node]);
const handleCreateSubfolder = useCallback(() => {
const newFolderId = node.createFolder(

View File

@@ -64,10 +64,9 @@ export const useExplorerTagNodeOperations = (
const newDoc = createPage();
tagRecord?.tag(newDoc.id);
track.$.navigationPanel.tags.createDoc();
workbenchService.workbench.openDoc(newDoc.id);
openNodeCollapsed();
}
}, [createPage, openNodeCollapsed, tagRecord, workbenchService.workbench]);
}, [createPage, openNodeCollapsed, tagRecord]);
const handleMoveToTrash = useCallback(() => {
tagService.tagList.deleteTag(tagId);

View File

@@ -13,7 +13,6 @@ import {
FavoriteService,
isFavoriteSupportType,
} from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { isNewTabTrigger } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
@@ -41,14 +40,8 @@ import {
import { RootEmpty } from './empty';
export const ExplorerFavorites = () => {
const {
favoriteService,
workspaceService,
workbenchService,
explorerService,
} = useServices({
const { favoriteService, workspaceService, explorerService } = useServices({
FavoriteService,
WorkbenchService,
WorkspaceService,
ExplorerService,
});
@@ -88,23 +81,18 @@ export const ExplorerFavorites = () => {
const handleCreateNewFavoriteDoc: MouseEventHandler = useCallback(
e => {
const newDoc = createPage();
const newDoc = createPage(
undefined,
isNewTabTrigger(e) ? 'new-tab' : true
);
favoriteService.favoriteList.add(
'doc',
newDoc.id,
favoriteService.favoriteList.indexAt('before')
);
workbenchService.workbench.openDoc(newDoc.id, {
at: isNewTabTrigger(e) ? 'new-tab' : 'active',
});
explorerSection.setCollapsed(false);
},
[
createPage,
explorerSection,
favoriteService.favoriteList,
workbenchService.workbench,
]
[createPage, explorerSection, favoriteService.favoriteList]
);
const handleOnChildrenDrop = useCallback(

View File

@@ -179,67 +179,3 @@ export const draggedOverEffect = style({
},
},
});
// ---------- mobile ----------
export const mobileItemRoot = style([
itemRoot,
{
padding: '8px',
borderRadius: 0,
flexDirection: 'row-reverse',
gap: 12,
selectors: {
'&:hover': {
background: 'none',
},
'&:active': {
background: cssVar('hoverColor'),
},
'&[data-active="true"]': {
background: 'transparent',
},
},
':after': {
content: '',
width: `calc(100% + ${levelIndent})`,
height: 0.5,
background: cssVar('borderColor'),
bottom: 0,
position: 'absolute',
right: 0,
},
},
]);
export const mobileItemMain = style([itemMain, {}]);
export const mobileIconContainer = style([
iconContainer,
{
width: 32,
height: 32,
fontSize: 24,
},
]);
export const mobileCollapsedIconContainer = style([
collapsedIconContainer,
{
fontSize: 16,
},
]);
export const mobileItemContent = style([
itemContent,
{
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
fontWeight: 400,
},
]);
export const mobileContentContainer = style([
contentContainer,
{
marginTop: 0,
},
]);

View File

@@ -37,7 +37,6 @@ import {
useState,
} from 'react';
import { ExplorerMobileContext } from '../mobile.context';
import { ExplorerTreeContext } from './context';
import { DropEffect } from './drop-effect';
import * as styles from './node.css';
@@ -57,6 +56,38 @@ export type ExplorerTreeNodeIcon = React.ComponentType<{
collapsed?: boolean;
}>;
export interface BaseExplorerTreeNodeProps {
name?: string;
icon?: ExplorerTreeNodeIcon;
children?: React.ReactNode;
active?: boolean;
defaultRenaming?: boolean;
extractEmojiAsIcon?: boolean;
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
renameable?: boolean;
onRename?: (newName: string) => 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>
>;
[key: `data-${string}`]: any;
}
interface WebExplorerTreeNodeProps extends BaseExplorerTreeNodeProps {
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
reorderable?: boolean;
dndData?: AffineDNDData;
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
dropEffect?: ExplorerTreeNodeDropEffect;
}
export const ExplorerTreeNode = ({
children,
icon: Icon,
@@ -82,34 +113,7 @@ export const ExplorerTreeNode = ({
onDrop,
dropEffect,
...otherProps
}: {
name?: string;
icon?: ExplorerTreeNodeIcon;
children?: React.ReactNode;
active?: boolean;
reorderable?: boolean;
defaultRenaming?: boolean;
extractEmojiAsIcon?: boolean;
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
renameable?: boolean;
onRename?: (newName: string) => void;
disabled?: boolean;
onClick?: () => void;
to?: To;
postfix?: React.ReactNode;
canDrop?: DropTargetOptions<AffineDNDData>['canDrop'];
operations?: NodeOperation[];
childrenOperations?: NodeOperation[];
childrenPlaceholder?: React.ReactNode;
linkComponent?: React.ComponentType<
React.PropsWithChildren<{ to: To; className?: string }> & RefAttributes<any>
>;
dndData?: AffineDNDData;
onDrop?: (data: DropTargetDropEvent<AffineDNDData>) => void;
dropEffect?: ExplorerTreeNodeDropEffect;
} & { [key in `data-${string}`]?: any }) => {
const mobile = useContext(ExplorerMobileContext);
}: WebExplorerTreeNodeProps) => {
const t = useI18n();
const cid = useId();
const context = useContext(ExplorerTreeContext);
@@ -141,21 +145,19 @@ export const ExplorerTreeNode = ({
AffineDNDData & { draggable: { __cid: string } }
>(
() => ({
canDrag: () => !mobile,
data: { ...dndData?.draggable, __cid: cid },
dragPreviewPosition: 'pointer-outside',
}),
[cid, dndData, mobile]
[cid, dndData]
);
const handleCanDrop = useMemo<DropTargetOptions<AffineDNDData>['canDrop']>(
() => args => {
if (mobile) return false;
if (!reorderable && args.treeInstruction?.type !== 'make-child') {
return false;
}
return (typeof canDrop === 'function' ? canDrop(args) : canDrop) ?? true;
},
[canDrop, mobile, reorderable]
[canDrop, reorderable]
);
const {
dropTargetRef,
@@ -319,7 +321,7 @@ export const ExplorerTreeNode = ({
const content = (
<div
onClick={handleClick}
className={mobile ? styles.mobileItemRoot : styles.itemRoot}
className={styles.itemRoot}
data-active={active}
data-disabled={disabled}
>
@@ -327,11 +329,7 @@ export const ExplorerTreeNode = ({
data-disabled={disabled}
onClick={handleCollapsedChange}
data-testid="explorer-collapsed-button"
className={
mobile
? styles.mobileCollapsedIconContainer
: styles.collapsedIconContainer
}
className={styles.collapsedIconContainer}
>
<ArrowDownSmallIcon
className={styles.collapsedIcon}
@@ -339,10 +337,8 @@ export const ExplorerTreeNode = ({
/>
</div>
<div className={clsx(mobile ? styles.mobileItemMain : styles.itemMain)}>
<div
className={mobile ? styles.mobileIconContainer : styles.iconContainer}
>
<div className={styles.itemMain}>
<div className={styles.iconContainer}>
{emoji ??
(Icon && (
<Icon
@@ -352,38 +348,34 @@ export const ExplorerTreeNode = ({
/>
))}
</div>
<div className={mobile ? styles.mobileItemContent : styles.itemContent}>
{name}
</div>
<div className={styles.itemContent}>{name}</div>
{postfix}
{mobile ? null : (
<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>
))}
<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 }}
>
<IconButton
size="16"
data-testid="explorer-tree-node-operation-button"
style={{ marginLeft: 4 }}
>
<MoreHorizontalIcon />
</IconButton>
</Menu>
)}
</div>
)}
<MoreHorizontalIcon />
</IconButton>
</Menu>
)}
</div>
</div>
{renameable && (
@@ -411,10 +403,7 @@ export const ExplorerTreeNode = ({
{...otherProps}
>
<div
className={clsx(
mobile ? styles.mobileContentContainer : styles.contentContainer,
styles.draggedOverEffect
)}
className={clsx(styles.contentContainer, styles.draggedOverEffect)}
data-open={!collapsed}
data-self-dragged-over={isSelfDraggedOver}
ref={dropTargetRef}

View File

@@ -949,6 +949,9 @@
"com.affine.rootAppSidebar.organize.folder.create-subfolder": "Create a subfolder",
"com.affine.rootAppSidebar.organize.new-folders": "New folder",
"com.affine.rootAppSidebar.organize.root-folder-only": "Only folder can be placed on here",
"com.affine.rootAppSidebar.organize.add-more": "Add More",
"com.affine.rootAppSidebar.organize.add-folder": "Add Folder",
"com.affine.rootAppSidebar.collection.new": "New Collection",
"com.affine.rootAppSidebar.others": "Others",
"com.affine.rootAppSidebar.tag.doc-only": "Only doc can be placed on here",
"com.affine.rootAppSidebar.tags": "Tags",