mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-06 09:33:45 +00:00
refactor(core): optimize abstraction of sidebar doc tree structure (#7455)
This commit is contained in:
@@ -29,7 +29,6 @@ import {
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
@@ -37,11 +36,11 @@ import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list
|
||||
import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta';
|
||||
import { WorkbenchService } from '../../../../modules/workbench';
|
||||
import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link';
|
||||
import { MenuLinkItem as SidebarMenuLinkItem } from '../../../app-sidebar';
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import * as draggableMenuItemStyles from '../components/draggable-menu-item.css';
|
||||
import { SidebarDocItem } from '../doc-tree/doc';
|
||||
import { SidebarDocTreeNode } from '../doc-tree/node';
|
||||
import type { CollectionsListProps } from '../index';
|
||||
import { Doc } from './doc';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
||||
@@ -60,7 +59,6 @@ export const CollectionSidebarNavItem = ({
|
||||
dndId: DNDIdentifier;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const collectionService = useService(CollectionService);
|
||||
const { createPage } = usePageHelper(docCollection);
|
||||
@@ -139,79 +137,78 @@ export const CollectionSidebarNavItem = ({
|
||||
});
|
||||
}, [createAndAddDocument, openConfirmModal, t]);
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={!collapsed}
|
||||
className={className}
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
const postfix = (
|
||||
<div
|
||||
onClick={stopPropagation}
|
||||
onMouseDown={e => {
|
||||
// prevent drag
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<SidebarMenuLinkItem
|
||||
{...listeners}
|
||||
data-draggable={true}
|
||||
data-dragging={isDragging}
|
||||
className={draggableMenuItemStyles.draggableMenuItem}
|
||||
data-testid="collection-item"
|
||||
data-collection-id={collection.id}
|
||||
data-type="collection-list-item"
|
||||
onCollapsedChange={setCollapsed}
|
||||
active={isOver || currentPath === path}
|
||||
icon={<AnimatedCollectionsIcon closed={isOver} />}
|
||||
to={path}
|
||||
linkComponent={WorkbenchLink}
|
||||
postfix={
|
||||
<div
|
||||
onClick={stopPropagation}
|
||||
onMouseDown={e => {
|
||||
// prevent drag
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<IconButton onClick={onConfirmAddDocToCollection} size="small">
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
<CollectionOperations
|
||||
collection={collection}
|
||||
openRenameModal={handleOpen}
|
||||
onAddDocToCollection={onConfirmAddDocToCollection}
|
||||
>
|
||||
<IconButton
|
||||
data-testid="collection-options"
|
||||
type="plain"
|
||||
size="small"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</CollectionOperations>
|
||||
<RenameModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onRename={onRename}
|
||||
currentName={collection.name}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
collapsed={collapsed}
|
||||
<IconButton onClick={onConfirmAddDocToCollection} size="small">
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
<CollectionOperations
|
||||
collection={collection}
|
||||
openRenameModal={handleOpen}
|
||||
onAddDocToCollection={onConfirmAddDocToCollection}
|
||||
>
|
||||
<span>{collection.name}</span>
|
||||
</SidebarMenuLinkItem>
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
{!collapsed && (
|
||||
<CollectionSidebarNavItemContent
|
||||
collection={collection}
|
||||
docCollection={docCollection}
|
||||
dndId={dndId}
|
||||
/>
|
||||
)}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
<IconButton
|
||||
data-testid="collection-options"
|
||||
type="plain"
|
||||
size="small"
|
||||
style={{ marginLeft: 4 }}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</CollectionOperations>
|
||||
<RenameModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onRename={onRename}
|
||||
currentName={collection.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarDocTreeNode
|
||||
ref={setNodeRef}
|
||||
node={{ type: 'collection', data: collection }}
|
||||
to={path}
|
||||
linkComponent={WorkbenchLink}
|
||||
subTree={
|
||||
<CollectionSidebarNavItemContent
|
||||
collection={collection}
|
||||
docCollection={docCollection}
|
||||
dndId={dndId}
|
||||
/>
|
||||
}
|
||||
rootProps={{
|
||||
className,
|
||||
style,
|
||||
...attributes,
|
||||
}}
|
||||
menuItemProps={{
|
||||
...listeners,
|
||||
'data-draggable': true,
|
||||
'data-dragging': isDragging,
|
||||
'data-testid': 'collection-item',
|
||||
'data-collection-id': collection.id,
|
||||
'data-type': 'collection-list-item',
|
||||
className: draggableMenuItemStyles.draggableMenuItem,
|
||||
active: isOver || currentPath === path,
|
||||
icon: <AnimatedCollectionsIcon closed={isOver} />,
|
||||
postfix,
|
||||
}}
|
||||
>
|
||||
<span>{collection.name}</span>
|
||||
</SidebarDocTreeNode>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionSidebarNavItemContent = ({
|
||||
const CollectionSidebarNavItemContent = ({
|
||||
collection,
|
||||
docCollection,
|
||||
dndId,
|
||||
@@ -254,12 +251,20 @@ export const CollectionSidebarNavItemContent = ({
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map(page => {
|
||||
return (
|
||||
<Doc
|
||||
docId={page.id}
|
||||
parentId={dndId}
|
||||
inAllowList={allowList.has(page.id)}
|
||||
removeFromAllowList={removeFromAllowList}
|
||||
<SidebarDocItem
|
||||
key={page.id}
|
||||
docId={page.id}
|
||||
postfixConfig={{
|
||||
inAllowList: allowList.has(page.id),
|
||||
removeFromAllowList: removeFromAllowList,
|
||||
}}
|
||||
dragConfig={{
|
||||
parentId: dndId,
|
||||
where: 'collection-list',
|
||||
}}
|
||||
menuItemProps={{
|
||||
'data-testid': 'collection-page',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Loading, Tooltip } from '@affine/component';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import {
|
||||
WorkbenchLink,
|
||||
WorkbenchService,
|
||||
} from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import {
|
||||
DocsService,
|
||||
LiveData,
|
||||
useLiveData,
|
||||
useService,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
type DNDIdentifier,
|
||||
getDNDId,
|
||||
} from '../../../../hooks/affine/use-global-dnd-helper';
|
||||
import { MenuLinkItem } from '../../../app-sidebar';
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import { PostfixItem } from '../components/postfix-item';
|
||||
import { ReferencePage } from '../components/reference-page';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const Doc = ({
|
||||
docId,
|
||||
parentId,
|
||||
inAllowList,
|
||||
removeFromAllowList,
|
||||
}: {
|
||||
parentId: DNDIdentifier;
|
||||
docId: string;
|
||||
inAllowList: boolean;
|
||||
removeFromAllowList: (id: string) => void;
|
||||
}) => {
|
||||
const { docsSearchService, workbenchService } = useServices({
|
||||
DocsSearchService,
|
||||
WorkbenchService,
|
||||
DocsService,
|
||||
});
|
||||
const t = useI18n();
|
||||
const location = useLiveData(workbenchService.workbench.location$);
|
||||
const active = location.pathname === '/' + docId;
|
||||
|
||||
const [collapsed, setCollapsed] = React.useState(true);
|
||||
const docRecord = useLiveData(useService(DocsService).list.doc$(docId));
|
||||
const docMode = useLiveData(docRecord?.mode$);
|
||||
const docTitle = useLiveData(docRecord?.title$);
|
||||
const icon = useMemo(() => {
|
||||
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
}, [docMode]);
|
||||
const references = 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);
|
||||
useEffect(() => {
|
||||
setReferencesLoading(
|
||||
prev =>
|
||||
prev &&
|
||||
indexerLoading /* after loading becomes false, it never becomes true */
|
||||
);
|
||||
}, [indexerLoading]);
|
||||
const untitled = !docTitle;
|
||||
|
||||
const dragItemId = getDNDId('collection-list', 'doc', docId, parentId);
|
||||
|
||||
const title = docTitle || t['Untitled']();
|
||||
const docTitleElement = useMemo(() => {
|
||||
return <DragMenuItemOverlay icon={icon} title={docTitle} />;
|
||||
}, [icon, docTitle]);
|
||||
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: dragItemId,
|
||||
data: {
|
||||
preview: docTitleElement,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
open={!collapsed}
|
||||
data-draggable={true}
|
||||
data-dragging={isDragging}
|
||||
>
|
||||
<MenuLinkItem
|
||||
data-testid="collection-page"
|
||||
data-type="collection-list-item"
|
||||
icon={icon}
|
||||
to={`/${docId}`}
|
||||
linkComponent={WorkbenchLink}
|
||||
className={styles.title}
|
||||
active={active}
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
postfix={
|
||||
<PostfixItem
|
||||
pageId={docId}
|
||||
pageTitle={title}
|
||||
removeFromAllowList={removeFromAllowList}
|
||||
inAllowList={inAllowList}
|
||||
/>
|
||||
}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className={styles.labelContainer}>
|
||||
<span className={styles.label} data-untitled={untitled}>
|
||||
{title || t['Untitled']()}
|
||||
</span>
|
||||
{!collapsed && referencesLoading && (
|
||||
<Tooltip
|
||||
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
||||
>
|
||||
<div className={styles.labelTooltipContainer}>
|
||||
<Loading />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</MenuLinkItem>
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
{references ? (
|
||||
references.length > 0 ? (
|
||||
references.map(({ docId: childDocId }) => {
|
||||
return (
|
||||
<ReferencePage
|
||||
key={childDocId}
|
||||
pageId={childDocId}
|
||||
parentIds={new Set([docId])}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={styles.noReferences}>
|
||||
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1 @@
|
||||
export * from './collections-list';
|
||||
export { Doc } from './doc';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const wrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
gap: 2,
|
||||
userSelect: 'none',
|
||||
// marginLeft:8,
|
||||
});
|
||||
@@ -23,37 +23,6 @@ export const viewTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const title = style({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
globalStyle(`[data-draggable=true] ${title}:before`, {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
left: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
transition: 'height 0.2s, opacity 0.2s',
|
||||
backgroundColor: cssVar('placeholderColor'),
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
});
|
||||
globalStyle(`[data-draggable=true] ${title}:hover:before`, {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
});
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${title}`, {
|
||||
opacity: 0.5,
|
||||
});
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${title}:before`, {
|
||||
height: 32,
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
});
|
||||
export const more = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -91,23 +60,6 @@ export const collapsibleContent = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
export const label = style({
|
||||
selectors: {
|
||||
'&[data-untitled="true"]': {
|
||||
opacity: 0.6,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const labelContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const labelTooltipContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const emptyCollectionWrapper = style({
|
||||
padding: '9px 0',
|
||||
display: 'flex',
|
||||
@@ -146,10 +98,9 @@ export const emptyCollectionNewButton = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
export const docsListContainer = style({
|
||||
marginLeft: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
gap: 2,
|
||||
});
|
||||
export const noReferences = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AddFavouriteButton } from '../favorite/add-favourite-button';
|
||||
import * as styles from '../favorite/styles.css';
|
||||
import { OperationMenuButton } from './operation-menu-button';
|
||||
|
||||
type PostfixItemProps = {
|
||||
export type PostfixItemProps = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
inFavorites?: boolean;
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Loading, Tooltip } from '@affine/component';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import {
|
||||
WorkbenchLink,
|
||||
WorkbenchService,
|
||||
} from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import {
|
||||
DocsService,
|
||||
LiveData,
|
||||
useLiveData,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MenuLinkItem } from '../../../app-sidebar';
|
||||
import * as styles from '../favorite/styles.css';
|
||||
import { PostfixItem } from './postfix-item';
|
||||
export interface ReferencePageProps {
|
||||
pageId: string;
|
||||
parentIds?: Set<string>;
|
||||
}
|
||||
|
||||
export const ReferencePage = ({ pageId, parentIds }: ReferencePageProps) => {
|
||||
const t = useI18n();
|
||||
const { docsSearchService, workbenchService, docsService } = useServices({
|
||||
DocsSearchService,
|
||||
WorkbenchService,
|
||||
DocsService,
|
||||
});
|
||||
const workbench = workbenchService.workbench;
|
||||
const location = useLiveData(workbench.location$);
|
||||
const linkActive = location.pathname === '/' + pageId;
|
||||
const docRecord = useLiveData(docsService.list.doc$(pageId));
|
||||
const docMode = useLiveData(docRecord?.mode$);
|
||||
const docTitle = useLiveData(docRecord?.title$);
|
||||
const icon = useMemo(() => {
|
||||
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
}, [docMode]);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const references = useLiveData(
|
||||
useMemo(
|
||||
() => LiveData.from(docsSearchService.watchRefsFrom(pageId), null),
|
||||
[docsSearchService, pageId]
|
||||
)
|
||||
);
|
||||
const indexerLoading = useLiveData(
|
||||
docsSearchService.indexer.status$.map(
|
||||
v => v.remaining === undefined || v.remaining > 0
|
||||
)
|
||||
);
|
||||
const [referencesLoading, setReferencesLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setReferencesLoading(
|
||||
prev =>
|
||||
prev &&
|
||||
indexerLoading /* after loading becomes false, it never becomes true */
|
||||
);
|
||||
}, [indexerLoading]);
|
||||
const nestedItem = parentIds && parentIds.size > 0;
|
||||
const untitled = !docTitle;
|
||||
const pageTitle = docTitle || t['Untitled']();
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
className={styles.favItemWrapper}
|
||||
data-nested={nestedItem}
|
||||
open={!collapsed}
|
||||
>
|
||||
<MenuLinkItem
|
||||
data-type="reference-page"
|
||||
data-testid={`reference-page-${pageId}`}
|
||||
active={linkActive}
|
||||
to={`/${pageId}`}
|
||||
icon={icon}
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
linkComponent={WorkbenchLink}
|
||||
postfix={
|
||||
<PostfixItem
|
||||
pageId={pageId}
|
||||
pageTitle={pageTitle}
|
||||
isReferencePage={true}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.labelContainer}>
|
||||
<span className={styles.label} data-untitled={untitled}>
|
||||
{pageTitle}
|
||||
</span>
|
||||
{!collapsed && referencesLoading && (
|
||||
<Tooltip
|
||||
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
||||
>
|
||||
<div className={styles.labelTooltipContainer}>
|
||||
<Loading />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</MenuLinkItem>
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
<div className={styles.collapsibleContentInner}>
|
||||
{references ? (
|
||||
references.length > 0 ? (
|
||||
references.map(({ docId }) => {
|
||||
return (
|
||||
<ReferencePage
|
||||
key={docId}
|
||||
pageId={docId}
|
||||
parentIds={new Set([pageId])}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={styles.noReferences}>
|
||||
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const title = style({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true] ${title}:before`, {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
left: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
transition: 'height 0.2s, opacity 0.2s',
|
||||
backgroundColor: cssVar('placeholderColor'),
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
});
|
||||
globalStyle(`[data-draggable=true] ${title}:hover:before`, {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
});
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${title}`, {
|
||||
opacity: 0.5,
|
||||
});
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${title}:before`, {
|
||||
height: 32,
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
selectors: {
|
||||
'&[data-untitled="true"]': {
|
||||
opacity: 0.6,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const labelContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const labelTooltipContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const noReferences = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
textAlign: 'left',
|
||||
paddingLeft: '32px',
|
||||
color: cssVar('black30'),
|
||||
userSelect: 'none',
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Loading, Tooltip } from '@affine/component';
|
||||
import type { MenuItemProps } from '@affine/core/components/app-sidebar';
|
||||
import {
|
||||
type DNDIdentifier,
|
||||
type DndWhere,
|
||||
getDNDId,
|
||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import {
|
||||
WorkbenchLink,
|
||||
WorkbenchService,
|
||||
} from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import {
|
||||
DocsService,
|
||||
LiveData,
|
||||
useLiveData,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import { PostfixItem, type PostfixItemProps } from '../components/postfix-item';
|
||||
import * as styles from './doc.css';
|
||||
import { SidebarDocTreeNode } from './node';
|
||||
|
||||
export type SidebarDocItemProps = {
|
||||
docId: string;
|
||||
postfixConfig?: Omit<
|
||||
PostfixItemProps,
|
||||
'pageId' | 'pageTitle' | 'isReferencePage'
|
||||
>;
|
||||
isReference?: boolean;
|
||||
dragConfig?: {
|
||||
parentId?: DNDIdentifier;
|
||||
where: DndWhere;
|
||||
};
|
||||
menuItemProps?: Partial<MenuItemProps> & Record<`data-${string}`, string>;
|
||||
};
|
||||
|
||||
export const SidebarDocItem = function SidebarDocItem({
|
||||
docId,
|
||||
postfixConfig,
|
||||
isReference,
|
||||
dragConfig,
|
||||
menuItemProps,
|
||||
}: SidebarDocItemProps) {
|
||||
const { docsSearchService, workbenchService, docsService } = useServices({
|
||||
DocsSearchService,
|
||||
WorkbenchService,
|
||||
DocsService,
|
||||
});
|
||||
const t = useI18n();
|
||||
const location = useLiveData(workbenchService.workbench.location$);
|
||||
const active = location.pathname === '/' + docId;
|
||||
|
||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||
const docMode = useLiveData(docRecord?.mode$);
|
||||
const docTitle = useLiveData(docRecord?.title$);
|
||||
const icon = useMemo(() => {
|
||||
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||
}, [docMode]);
|
||||
|
||||
const references = 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);
|
||||
useEffect(() => {
|
||||
setReferencesLoading(
|
||||
prev =>
|
||||
prev &&
|
||||
indexerLoading /* after loading becomes false, it never becomes true */
|
||||
);
|
||||
}, [indexerLoading]);
|
||||
const untitled = !docTitle;
|
||||
const title = docTitle || t['Untitled']();
|
||||
|
||||
// drag (not available for sub-docs)
|
||||
const dragItemId = dragConfig
|
||||
? getDNDId(dragConfig.where, 'doc', docId, dragConfig.parentId)
|
||||
: nanoid();
|
||||
const docTitleElement = useMemo(() => {
|
||||
return <DragMenuItemOverlay icon={icon} title={docTitle} />;
|
||||
}, [icon, docTitle]);
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: dragItemId,
|
||||
data: { preview: docTitleElement },
|
||||
disabled: !dragConfig || isReference,
|
||||
});
|
||||
|
||||
const dragAttrs: Partial<MenuItemProps> = isReference
|
||||
? {
|
||||
// prevent dragging parent node
|
||||
onMouseDown: e => e.stopPropagation(),
|
||||
}
|
||||
: { ...attributes, ...listeners };
|
||||
|
||||
// workaround to avoid invisible in playwright caused by nested drag
|
||||
delete dragAttrs['aria-disabled'];
|
||||
|
||||
return (
|
||||
<SidebarDocTreeNode
|
||||
ref={setNodeRef}
|
||||
rootProps={{ 'data-dragging': isDragging }}
|
||||
node={{ type: 'doc', data: docId }}
|
||||
to={`/${docId}`}
|
||||
linkComponent={WorkbenchLink}
|
||||
menuItemProps={{
|
||||
'data-type': isReference ? 'reference-page' : undefined,
|
||||
icon,
|
||||
active,
|
||||
className: styles.title,
|
||||
postfix: (
|
||||
<PostfixItem
|
||||
pageId={docId}
|
||||
pageTitle={title}
|
||||
isReferencePage={isReference}
|
||||
{...postfixConfig}
|
||||
/>
|
||||
),
|
||||
...dragAttrs,
|
||||
...menuItemProps,
|
||||
}}
|
||||
subTree={
|
||||
references ? (
|
||||
references.length > 0 ? (
|
||||
references.map(({ docId: childDocId }) => {
|
||||
return (
|
||||
<SidebarDocItem
|
||||
key={childDocId}
|
||||
docId={childDocId}
|
||||
isReference={true}
|
||||
menuItemProps={{
|
||||
'data-testid': `reference-page-${childDocId}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={styles.noReferences}>
|
||||
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className={styles.labelContainer}>
|
||||
<span className={styles.label} data-untitled={untitled}>
|
||||
{title || t['Untitled']()}
|
||||
</span>
|
||||
{referencesLoading && (
|
||||
<Tooltip
|
||||
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
||||
>
|
||||
<div className={styles.labelTooltipContainer}>
|
||||
<Loading />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</SidebarDocTreeNode>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const collapseContent = style({
|
||||
paddingTop: 2,
|
||||
paddingLeft: 20,
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
MenuItem,
|
||||
type MenuItemProps,
|
||||
MenuLinkItem,
|
||||
} from '@affine/core/components/app-sidebar';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Link, type To } from 'react-router-dom';
|
||||
|
||||
import * as styles from './node.css';
|
||||
|
||||
type SidebarDocTreeNode =
|
||||
| {
|
||||
type: 'collection';
|
||||
data: Collection;
|
||||
}
|
||||
// | { type: 'tag' }
|
||||
// | { type: 'folder' }
|
||||
| {
|
||||
type: 'doc';
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type SidebarDocTreeNodeProps = PropsWithChildren<{
|
||||
node: SidebarDocTreeNode;
|
||||
subTree?: ReactNode;
|
||||
to?: To;
|
||||
linkComponent?: React.ComponentType<{ to: To; className?: string }>;
|
||||
|
||||
menuItemProps?: MenuItemProps & Record<`data-${string}`, unknown>;
|
||||
rootProps?: Collapsible.CollapsibleProps & Record<`data-${string}`, unknown>;
|
||||
}>;
|
||||
|
||||
type SidebarDocTreeNodeContext = {
|
||||
ancestors: SidebarDocTreeNode[];
|
||||
};
|
||||
|
||||
export const sidebarDocTreeContext =
|
||||
createContext<SidebarDocTreeNodeContext | null>(null);
|
||||
|
||||
/**
|
||||
* Tree node for the sidebar doc/folder/tag/collection tree.
|
||||
* This component is used to manage:
|
||||
* - Collapsing state
|
||||
* - Ancestors context
|
||||
* - Link/Menu item rendering
|
||||
* - Subtree indentation (left/top)
|
||||
*/
|
||||
export const SidebarDocTreeNode = forwardRef(function SidebarDocTreeNode(
|
||||
{
|
||||
node,
|
||||
children,
|
||||
subTree,
|
||||
to,
|
||||
linkComponent: LinkComponent = Link,
|
||||
menuItemProps,
|
||||
rootProps,
|
||||
}: SidebarDocTreeNodeProps,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const { ancestors } = useContext(sidebarDocTreeContext) ?? { ancestors: [] };
|
||||
|
||||
const finalMenuItemProps: SidebarDocTreeNodeProps['menuItemProps'] = {
|
||||
...menuItemProps,
|
||||
collapsed,
|
||||
onCollapsedChange: setCollapsed,
|
||||
};
|
||||
|
||||
return (
|
||||
<sidebarDocTreeContext.Provider value={{ ancestors: [...ancestors, node] }}>
|
||||
<Collapsible.Root
|
||||
{...rootProps}
|
||||
ref={ref}
|
||||
open={!collapsed}
|
||||
onOpenChange={setCollapsed}
|
||||
>
|
||||
{to ? (
|
||||
<MenuLinkItem
|
||||
to={to}
|
||||
linkComponent={LinkComponent}
|
||||
{...finalMenuItemProps}
|
||||
>
|
||||
{children}
|
||||
</MenuLinkItem>
|
||||
) : (
|
||||
<MenuItem {...finalMenuItemProps}>{children}</MenuItem>
|
||||
)}
|
||||
<Collapsible.Content className={styles.collapseContent}>
|
||||
{collapsed ? null : subTree}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</sidebarDocTreeContext.Provider>
|
||||
);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
|
||||
} else if (item.type === 'doc') {
|
||||
return (
|
||||
<FavouriteDocSidebarNavItem
|
||||
pageId={item.id}
|
||||
docId={item.id}
|
||||
// memo?
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
import { Loading, Tooltip } from '@affine/component';
|
||||
import {
|
||||
getDNDId,
|
||||
parseDNDId,
|
||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
||||
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||
import {
|
||||
WorkbenchLink,
|
||||
WorkbenchService,
|
||||
} from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import {
|
||||
DocsService,
|
||||
LiveData,
|
||||
useLiveData,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { MenuLinkItem } from '../../../app-sidebar';
|
||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||
import * as draggableMenuItemStyles from '../components/draggable-menu-item.css';
|
||||
import { PostfixItem } from '../components/postfix-item';
|
||||
import type { ReferencePageProps } from '../components/reference-page';
|
||||
import { ReferencePage } from '../components/reference-page';
|
||||
import { SidebarDocItem } from '../doc-tree/doc';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
||||
@@ -34,44 +18,12 @@ const animateLayoutChanges: AnimateLayoutChanges = ({
|
||||
wasDragging,
|
||||
}) => (isSorting || wasDragging ? false : true);
|
||||
|
||||
export const FavouriteDocSidebarNavItem = ({
|
||||
pageId,
|
||||
}: ReferencePageProps & {
|
||||
sortable?: boolean;
|
||||
}) => {
|
||||
export const FavouriteDocSidebarNavItem = ({ docId }: { docId: string }) => {
|
||||
const t = useI18n();
|
||||
const { docsSearchService, workbenchService, docsService } = useServices({
|
||||
DocsSearchService,
|
||||
WorkbenchService,
|
||||
DocsService,
|
||||
});
|
||||
const workbench = workbenchService.workbench;
|
||||
const location = useLiveData(workbench.location$);
|
||||
const linkActive = location.pathname === '/' + pageId;
|
||||
const docRecord = useLiveData(docsService.list.doc$(pageId));
|
||||
const docsService = useService(DocsService);
|
||||
const docRecord = useLiveData(docsService.list.doc$(docId));
|
||||
const docMode = useLiveData(docRecord?.mode$);
|
||||
const docTitle = useLiveData(docRecord?.title$);
|
||||
const references = useLiveData(
|
||||
useMemo(
|
||||
() => LiveData.from(docsSearchService.watchRefsFrom(pageId), null),
|
||||
[docsSearchService, pageId]
|
||||
)
|
||||
);
|
||||
const indexerLoading = useLiveData(
|
||||
docsSearchService.indexer.status$.map(
|
||||
v => v.remaining === undefined || v.remaining > 0
|
||||
)
|
||||
);
|
||||
const [referencesLoading, setReferencesLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setReferencesLoading(
|
||||
prev =>
|
||||
prev &&
|
||||
indexerLoading /* after loading becomes false, it never becomes true */
|
||||
);
|
||||
}, [indexerLoading]);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const untitled = !docTitle;
|
||||
const pageTitle = docTitle || t['Untitled']();
|
||||
|
||||
const icon = useMemo(() => {
|
||||
@@ -82,7 +34,7 @@ export const FavouriteDocSidebarNavItem = ({
|
||||
return <DragMenuItemOverlay icon={icon} title={pageTitle} />;
|
||||
}, [icon, pageTitle]);
|
||||
|
||||
const dragItemId = getDNDId('sidebar-pin', 'doc', pageId);
|
||||
const dragItemId = getDNDId('sidebar-pin', 'doc', docId);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
@@ -107,68 +59,23 @@ export const FavouriteDocSidebarNavItem = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
<div
|
||||
className={styles.favItemWrapper}
|
||||
open={!collapsed}
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
data-draggable={true}
|
||||
data-dragging={isDragging}
|
||||
data-testid={`favourite-page-${docId}`}
|
||||
data-favourite-page-item
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<MenuLinkItem
|
||||
{...listeners}
|
||||
data-testid={`favourite-page-${pageId}`}
|
||||
data-favourite-page-item
|
||||
icon={icon}
|
||||
data-draggable={true}
|
||||
data-dragging={isDragging}
|
||||
className={draggableMenuItemStyles.draggableMenuItem}
|
||||
active={linkActive}
|
||||
to={`/${pageId}`}
|
||||
linkComponent={WorkbenchLink}
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
postfix={
|
||||
<PostfixItem
|
||||
pageId={pageId}
|
||||
pageTitle={pageTitle}
|
||||
inFavorites={true}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.labelContainer}>
|
||||
<span className={styles.label} data-untitled={untitled}>
|
||||
{pageTitle}
|
||||
</span>
|
||||
{!collapsed && referencesLoading && (
|
||||
<Tooltip
|
||||
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
||||
>
|
||||
<div className={styles.labelTooltipContainer}>
|
||||
<Loading />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</MenuLinkItem>
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
{references ? (
|
||||
references.length > 0 ? (
|
||||
references.map(({ docId }) => {
|
||||
return (
|
||||
<ReferencePage
|
||||
key={docId}
|
||||
pageId={docId}
|
||||
parentIds={new Set([pageId])}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={styles.noReferences}>
|
||||
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
<SidebarDocItem
|
||||
docId={docId}
|
||||
postfixConfig={{
|
||||
inFavorites: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,15 +22,6 @@ export const favItemWrapper = style({
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
selectors: {
|
||||
'&[data-nested="true"]': {
|
||||
marginLeft: '20px',
|
||||
width: 'calc(100% - 20px)',
|
||||
},
|
||||
'&:not(:first-of-type)': {
|
||||
marginTop: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const collapsibleContent = style({
|
||||
overflow: 'hidden',
|
||||
@@ -71,10 +62,13 @@ globalStyle(`${dragPageItemOverlay} span`, {
|
||||
});
|
||||
export const favoriteList = style({
|
||||
overflow: 'hidden',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
selectors: {
|
||||
'&[data-over="true"]': {
|
||||
background: cssVar('hoverColorFilled'),
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user