refactor(core): use new backlink indexer (#7296)

This commit is contained in:
EYHN
2024-07-02 09:18:01 +00:00
parent 40e381e272
commit 27d0fc5108
33 changed files with 826 additions and 357 deletions

View File

@@ -8,7 +8,7 @@ import {
Tooltip,
} from '@affine/component';
import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
import { useBlockSuitePageBacklinks } from '@affine/core/hooks/use-block-suite-page-backlinks';
import { DocLinksService } from '@affine/core/modules/doc-link';
import type {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
@@ -380,7 +380,7 @@ export const PagePropertiesSettingsPopup = ({
};
type PageBacklinksPopupProps = PropsWithChildren<{
backlinks: string[];
backlinks: { docId: string; blockId: string; title: string }[];
}>;
export const PageBacklinksPopup = ({
@@ -398,11 +398,11 @@ export const PageBacklinksPopup = ({
}}
items={
<div className={styles.backlinksList}>
{backlinks.map(pageId => (
{backlinks.map(link => (
<AffinePageReference
key={pageId}
key={link.docId + ':' + link.blockId}
wrapper={MenuItem}
pageId={pageId}
pageId={link.docId}
docCollection={manager.workspace.docCollection}
/>
))}
@@ -597,10 +597,11 @@ export const PagePropertiesTableHeader = ({
const manager = useContext(managerContext);
const t = useI18n();
const backlinks = useBlockSuitePageBacklinks(
manager.workspace.docCollection,
manager.pageId
);
const { docLinksServices } = useServices({
DocLinksServices: DocLinksService,
});
const docBacklinks = docLinksServices.backlinks;
const backlinks = useLiveData(docBacklinks.backlinks$);
const { docService, workspaceService } = useServices({
DocService,

View File

@@ -0,0 +1,72 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const container = style({
width: '100%',
maxWidth: cssVar('--affine-editor-width'),
marginLeft: 'auto',
marginRight: 'auto',
paddingLeft: cssVar('--affine-editor-side-padding', '24'),
paddingRight: cssVar('--affine-editor-side-padding', '24'),
fontSize: cssVar('--affine-font-base'),
});
export const dividerContainer = style({
height: '16px',
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const divider = style({
background: cssVar('--affine-border-color'),
height: '0.5px',
width: '100%',
});
export const titleLine = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const title = style({
fontWeight: 500,
fontSize: '15px',
lineHeight: '24px',
color: cssVar('--affine-text-primary-color'),
});
export const showButton = style({
width: '56px',
height: '28px',
borderRadius: '8px',
border: '1px solid ' + cssVar('--affine-border-color'),
backgroundColor: cssVar('--affine-white'),
textAlign: 'center',
fontSize: '12px',
lineHeight: '28px',
fontWeight: '500',
color: cssVar('--affine-text-primary-color'),
cursor: 'pointer',
});
export const linksContainer = style({
marginBottom: '16px',
});
export const linksTitles = style({
color: cssVar('--affine-text-secondary-color'),
height: '32px',
lineHeight: '32px',
});
export const link = style({
width: '100%',
height: '32px',
display: 'flex',
alignItems: 'center',
gap: '4px',
whiteSpace: 'nowrap',
});

View File

@@ -0,0 +1,77 @@
import { DocLinksService } from '@affine/core/modules/doc-link';
import {
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { AffinePageReference } from '../../affine/reference-link';
import * as styles from './bi-directional-link-panel.css';
export const BiDirectionalLinkPanel = () => {
const [show, setShow] = useState(false);
const { docLinksService, workspaceService } = useServices({
DocLinksService,
WorkspaceService,
});
const links = useLiveData(docLinksService.links.links$);
const backlinks = useLiveData(docLinksService.backlinks.backlinks$);
const handleClickShow = useCallback(() => {
setShow(!show);
}, [show]);
return (
<div className={styles.container}>
{!show && (
<div className={styles.dividerContainer}>
<div className={styles.divider}></div>
</div>
)}
<div className={styles.titleLine}>
<div className={styles.title}>Bi-Directional Links</div>
<div className={styles.showButton} onClick={handleClickShow}>
{show ? 'Hide' : 'Show'}
</div>
</div>
{show && (
<>
<div className={styles.dividerContainer}>
<div className={styles.divider}></div>
</div>
<div className={styles.linksContainer}>
<div className={styles.linksTitles}>
Backlinks · {backlinks.length}
</div>
{backlinks.map(link => (
<div key={link.docId} className={styles.link}>
<AffinePageReference
key={link.docId}
pageId={link.docId}
docCollection={workspaceService.workspace.docCollection}
/>
</div>
))}
</div>
<div className={styles.linksContainer}>
<div className={styles.linksTitles}>
Outgoing links · {links.length}
</div>
{links.map(link => (
<div key={link.docId} className={styles.link}>
<AffinePageReference
pageId={link.docId}
docCollection={workspaceService.workspace.docCollection}
/>
</div>
))}
</div>
</>
)}
</div>
);
};

View File

@@ -7,7 +7,6 @@ import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { WorkbenchService } from '@affine/core/modules/workbench';
import {
BiDirectionalLinkPanel,
DocMetaTags,
DocTitle,
EdgelessEditor,
@@ -34,6 +33,7 @@ import React, {
import { PagePropertiesTable } from '../../affine/page-properties';
import { AffinePageReference } from '../../affine/reference-link';
import { BiDirectionalLinkPanel } from './bi-directional-link-panel';
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
import {
patchDocModeService,
@@ -65,10 +65,6 @@ const adapted = {
react: React,
elementClass: EdgelessEditor,
}),
BiDirectionalLinkPanel: createReactComponentFromLit({
react: React,
elementClass: BiDirectionalLinkPanel,
}),
};
interface BlocksuiteEditorProps {
@@ -211,9 +207,7 @@ export const BlocksuiteDocEditor = forwardRef<
}}
></div>
) : null}
{docPage && !page.readonly ? (
<adapted.BiDirectionalLinkPanel doc={page} pageRoot={docPage} />
) : null}
{!page.readonly ? <BiDirectionalLinkPanel /> : null}
</div>
{portals}
</>

View File

@@ -183,7 +183,10 @@ export const ItemGroup = <T extends ListItem>({
) : null}
</div>
) : null}
<Collapsible.Content className={styles.collapsibleContent}>
<Collapsible.Content
className={styles.collapsibleContent}
data-state={!collapsed ? 'open' : 'closed'}
>
<div className={styles.collapsibleContentInner}>
{items.map(item => (
<PageListItemRenderer key={item.id} {...item} />

View File

@@ -231,10 +231,6 @@ export const CollectionSidebarNavItemContent = ({
() => new Set(collection.allowList),
[collection.allowList]
);
const allPagesMeta = useMemo(
() => Object.fromEntries(pages.map(v => [v.id, v])),
[pages]
);
const removeFromAllowList = useCallback(
(id: string) => {
collectionService.deletePageFromCollection(collection.id, id);
@@ -259,18 +255,16 @@ export const CollectionSidebarNavItemContent = ({
filtered.map(page => {
return (
<Doc
docId={page.id}
parentId={dndId}
inAllowList={allowList.has(page.id)}
removeFromAllowList={removeFromAllowList}
allPageMeta={allPagesMeta}
doc={page}
key={page.id}
docCollection={docCollection}
/>
);
})
) : (
<div className={styles.emptyCollection}>
<div className={styles.noReferences}>
{t['com.affine.collection.emptyCollection']()}
</div>
)}

View File

@@ -1,15 +1,21 @@
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
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 type { DocCollection, DocMeta } from '@blocksuite/store';
import { useDraggable } from '@dnd-kit/core';
import * as Collapsible from '@radix-ui/react-collapsible';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import React, { useMemo } from 'react';
import {
DocsService,
LiveData,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import React, { useEffect, useMemo, useState } from 'react';
import {
type DNDIdentifier,
@@ -22,42 +28,56 @@ import { ReferencePage } from '../components/reference-page';
import * as styles from './styles.css';
export const Doc = ({
doc,
docId,
parentId,
docCollection,
allPageMeta,
inAllowList,
removeFromAllowList,
}: {
parentId: DNDIdentifier;
doc: DocMeta;
docId: string;
inAllowList: boolean;
removeFromAllowList: (id: string) => void;
docCollection: DocCollection;
allPageMeta: Record<string, DocMeta>;
}) => {
const [collapsed, setCollapsed] = React.useState(true);
const workbench = useService(WorkbenchService).workbench;
const location = useLiveData(workbench.location$);
const { docsSearchService, workbenchService } = useServices({
DocsSearchService,
WorkbenchService,
DocsService,
});
const t = useI18n();
const docId = doc.id;
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 dragItemId = getDNDId('collection-list', 'doc', docId, parentId);
const docTitle = useLiveData(docRecord?.title$);
const icon = useMemo(() => {
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
}, [docMode]);
const references = useBlockSuitePageReferences(docCollection, docId);
const referencesToRender = references.filter(
id => allPageMeta[id] && !allPageMeta[id]?.trash
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 docTitle = doc.title || t['Untitled']();
const dragItemId = getDNDId('collection-list', 'doc', docId, parentId);
const title = docTitle || t['Untitled']();
const docTitleElement = useMemo(() => {
return <DragMenuItemOverlay icon={icon} title={docTitle} />;
}, [icon, docTitle]);
@@ -83,13 +103,12 @@ export const Doc = ({
linkComponent={WorkbenchLink}
className={styles.title}
active={active}
collapsed={referencesToRender.length > 0 ? collapsed : undefined}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
postfix={
<PostfixItem
docCollection={docCollection}
pageId={docId}
pageTitle={docTitle}
pageTitle={title}
removeFromAllowList={removeFromAllowList}
inAllowList={inAllowList}
/>
@@ -98,20 +117,39 @@ export const Doc = ({
{...attributes}
{...listeners}
>
{doc.title || t['Untitled']()}
<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}>
{referencesToRender.map(id => {
return (
<ReferencePage
key={id}
docCollection={docCollection}
pageId={id}
metaMapping={allPageMeta}
parentIds={new Set([docId])}
/>
);
})}
{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>
);

View File

@@ -1,5 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
export const wrapper = style({
display: 'flex',
flexDirection: 'column',
@@ -82,22 +82,6 @@ export const menuDividerStyle = style({
height: '1px',
background: cssVar('borderColor'),
});
const slideDown = keyframes({
'0%': {
height: '0px',
},
'100%': {
height: 'var(--radix-collapsible-content-height)',
},
});
const slideUp = keyframes({
'0%': {
height: 'var(--radix-collapsible-content-height)',
},
'100%': {
height: '0px',
},
});
export const collapsibleContent = style({
overflow: 'hidden',
marginTop: '4px',
@@ -105,14 +89,25 @@ export const collapsibleContent = style({
'&[data-hidden="true"]': {
display: 'none',
},
'&[data-state="open"]': {
animation: `${slideDown} 0.2s ease-in-out`,
},
'&[data-state="closed"]': {
animation: `${slideUp} 0.2s ease-in-out`,
},
});
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',
@@ -156,7 +151,7 @@ export const docsListContainer = style({
flexDirection: 'column',
gap: 4,
});
export const emptyCollection = style({
export const noReferences = style({
fontSize: cssVar('fontSm'),
textAlign: 'left',
paddingLeft: '32px',

View File

@@ -5,8 +5,7 @@ import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import type { DocCollection } from '@blocksuite/store';
import { useService } from '@toeverything/infra';
import { useService, useServices, WorkspaceService } from '@toeverything/infra';
import { useCallback } from 'react';
import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper';
@@ -15,7 +14,6 @@ import { OperationItems } from './operation-item';
export type OperationMenuButtonProps = {
pageId: string;
docCollection: DocCollection;
pageTitle: string;
setRenameModalOpen: () => void;
inFavorites?: boolean;
@@ -26,7 +24,6 @@ export type OperationMenuButtonProps = {
export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
const {
docCollection,
pageId,
pageTitle,
setRenameModalOpen,
@@ -36,8 +33,15 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
isReferencePage,
} = props;
const t = useI18n();
const { createLinkedPage } = usePageHelper(docCollection);
const { setTrashModal } = useTrashModalHelper(docCollection);
const { workspaceService } = useServices({
WorkspaceService,
});
const { createLinkedPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const { setTrashModal } = useTrashModalHelper(
workspaceService.workspace.docCollection
);
const favAdapter = useService(FavoriteItemsAdapter);
const workbench = useService(WorkbenchService).workbench;

View File

@@ -2,7 +2,7 @@ import { toast } from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { useI18n } from '@affine/i18n';
import type { DocCollection } from '@blocksuite/store';
import { useServices, WorkspaceService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { AddFavouriteButton } from '../favorite/add-favourite-button';
@@ -10,7 +10,6 @@ import * as styles from '../favorite/styles.css';
import { OperationMenuButton } from './operation-menu-button';
type PostfixItemProps = {
docCollection: DocCollection;
pageId: string;
pageTitle: string;
inFavorites?: boolean;
@@ -20,10 +19,15 @@ type PostfixItemProps = {
};
export const PostfixItem = ({ ...props }: PostfixItemProps) => {
const { docCollection, pageId, pageTitle } = props;
const { pageId, pageTitle } = props;
const t = useI18n();
const [open, setOpen] = useState(false);
const { setDocTitle } = useDocMetaHelper(docCollection);
const { workspaceService } = useServices({
WorkspaceService,
});
const { setDocTitle } = useDocMetaHelper(
workspaceService.workspace.docCollection
);
const handleRename = useCallback(
(newName: string) => {

View File

@@ -1,57 +1,67 @@
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
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 type { DocCollection, DocMeta } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import { useMemo, useState } from 'react';
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 {
docCollection: DocCollection;
pageId: string;
metaMapping: Record<string, DocMeta>;
parentIds?: Set<string>;
}
export const ReferencePage = ({
docCollection,
pageId,
metaMapping,
parentIds,
}: ReferencePageProps) => {
export const ReferencePage = ({ pageId, parentIds }: ReferencePageProps) => {
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const { docsSearchService, workbenchService, docsService } = useServices({
DocsSearchService,
WorkbenchService,
DocsService,
});
const workbench = workbenchService.workbench;
const location = useLiveData(workbench.location$);
const active = location.pathname === '/' + pageId;
const pageRecord = useLiveData(useService(DocsService).list.doc$(pageId));
const pageMode = useLiveData(pageRecord?.mode$);
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 pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
}, [pageMode]);
const references = useBlockSuitePageReferences(docCollection, pageId);
const referencesToShow = useMemo(() => {
return [
...new Set(
references.filter(ref => metaMapping[ref] && !metaMapping[ref]?.trash)
),
];
}, [references, metaMapping]);
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
}, [docMode]);
const [collapsed, setCollapsed] = useState(true);
const collapsible = referencesToShow.length > 0;
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 = !metaMapping[pageId]?.title;
const pageTitle = metaMapping[pageId]?.title || t['Untitled']();
const untitled = !docTitle;
const pageTitle = docTitle || t['Untitled']();
return (
<Collapsible.Root
@@ -62,42 +72,56 @@ export const ReferencePage = ({
<MenuLinkItem
data-type="reference-page"
data-testid={`reference-page-${pageId}`}
active={active}
active={linkActive}
to={`/${pageId}`}
icon={icon}
collapsed={collapsible ? collapsed : undefined}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
linkComponent={WorkbenchLink}
postfix={
<PostfixItem
docCollection={docCollection}
pageId={pageId}
pageTitle={pageTitle}
isReferencePage={true}
/>
}
>
<span className={styles.label} data-untitled={untitled}>
{pageTitle}
</span>
<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 && (
<Collapsible.Content className={styles.collapsibleContent}>
<div className={styles.collapsibleContentInner}>
{referencesToShow.map(ref => {
return (
<ReferencePage
key={ref}
docCollection={docCollection}
pageId={ref}
metaMapping={metaMapping}
parentIds={new Set([...(parentIds ?? []), pageId])}
/>
);
})}
</div>
</Collapsible.Content>
)}
<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>
);
};

View File

@@ -3,21 +3,21 @@ import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { mixpanel } from '@affine/core/utils';
import { PlusIcon } from '@blocksuite/icons/rc';
import type { DocCollection } from '@blocksuite/store';
import { useService } from '@toeverything/infra';
import { useService, useServices, WorkspaceService } from '@toeverything/infra';
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
type AddFavouriteButtonProps = {
docCollection: DocCollection;
pageId?: string;
};
export const AddFavouriteButton = ({
docCollection,
pageId,
}: AddFavouriteButtonProps) => {
const { createPage, createLinkedPage } = usePageHelper(docCollection);
export const AddFavouriteButton = ({ pageId }: AddFavouriteButtonProps) => {
const { workspaceService } = useServices({
WorkspaceService,
});
const { createPage, createLinkedPage } = usePageHelper(
workspaceService.workspace.docCollection
);
const favAdapter = useService(FavoriteItemsAdapter);
const handleAddFavorite = useAsyncCallback(
async e => {

View File

@@ -3,18 +3,21 @@ import {
getDNDId,
resolveDragEndIntent,
} from '@affine/core/hooks/affine/use-global-dnd-helper';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { CollectionService } from '@affine/core/modules/collection';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import type { WorkspaceFavoriteItem } from '@affine/core/modules/properties/services/schema';
import { useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/store';
import { useDndContext, useDroppable } from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useLiveData, useService } from '@toeverything/infra';
import {
DocsService,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import { Fragment, useCallback, useMemo } from 'react';
import { CollectionSidebarNavItem } from '../collections';
@@ -25,28 +28,24 @@ import { FavouriteDocSidebarNavItem } from './favourite-nav-item';
import * as styles from './styles.css';
const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
const metas = useBlockSuiteDocMeta(workspace);
const favAdapter = useService(FavoriteItemsAdapter);
const collections = useLiveData(useService(CollectionService).collections$);
const { favoriteItemsAdapter, docsService, collectionService } = useServices({
FavoriteItemsAdapter,
DocsService,
CollectionService,
});
const collections = useLiveData(collectionService.collections$);
const docs = useLiveData(docsService.list.docs$);
const trashDocs = useLiveData(docsService.list.trashDocs$);
const dropItemId = getDNDId('sidebar-pin', 'container', workspace.id);
const docMetaMapping = useMemo(
() =>
metas.reduce(
(acc, meta) => {
acc[meta.id] = meta;
return acc;
},
{} as Record<string, DocMeta>
),
[metas]
);
const favourites = useLiveData(
favAdapter.orderedFavorites$.map(favs => {
favoriteItemsAdapter.orderedFavorites$.map(favs => {
return favs.filter(fav => {
if (fav.type === 'doc') {
return !!docMetaMapping[fav.id] && !docMetaMapping[fav.id].trash;
return (
docs.some(doc => doc.id === fav.id) &&
!trashDocs.some(doc => doc.id === fav.id)
);
}
return true;
});
@@ -82,19 +81,17 @@ const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
/>
);
}
} else if (item.type === 'doc' && !docMetaMapping[item.id].trash) {
} else if (item.type === 'doc') {
return (
<FavouriteDocSidebarNavItem
metaMapping={docMetaMapping}
pageId={item.id}
// memo?
docCollection={workspace}
/>
);
}
return null;
},
[collections, docMetaMapping, workspace]
[collections, workspace]
);
const t = useI18n();
@@ -107,7 +104,7 @@ const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
data-over={shouldRenderDragOver}
>
<CategoryDivider label={t['com.affine.rootAppSidebar.favorites']()}>
<AddFavouriteButton docCollection={workspace} />
<AddFavouriteButton />
</CategoryDivider>
{favourites.map(item => {
return <Fragment key={item.id}>{renderFavItem(item)}</Fragment>;

View File

@@ -1,8 +1,9 @@
import { Loading, Tooltip } from '@affine/component';
import {
getDNDId,
parseDNDId,
} from '@affine/core/hooks/affine/use-global-dnd-helper';
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import {
WorkbenchLink,
WorkbenchService,
@@ -12,8 +13,13 @@ 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, useLiveData, useService } from '@toeverything/infra';
import { useMemo, useState } from 'react';
import {
DocsService,
LiveData,
useLiveData,
useServices,
} from '@toeverything/infra';
import { useEffect, useMemo, useState } from 'react';
import { MenuLinkItem } from '../../../app-sidebar';
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
@@ -29,38 +35,49 @@ const animateLayoutChanges: AnimateLayoutChanges = ({
}) => (isSorting || wasDragging ? false : true);
export const FavouriteDocSidebarNavItem = ({
docCollection: workspace,
pageId,
metaMapping,
}: ReferencePageProps & {
sortable?: boolean;
}) => {
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
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(useService(DocsService).list.doc$(pageId));
const docRecord = useLiveData(docsService.list.doc$(pageId));
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(() => {
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
}, [docMode]);
const references = useBlockSuitePageReferences(workspace, pageId);
const referencesToShow = useMemo(() => {
return [
...new Set(
references.filter(ref => metaMapping[ref] && !metaMapping[ref]?.trash)
),
];
}, [references, metaMapping]);
const [collapsed, setCollapsed] = useState(true);
const collapsible = referencesToShow.length > 0;
const untitled = !metaMapping[pageId]?.title;
const pageTitle = metaMapping[pageId]?.title || t['Untitled']();
const overlayPreview = useMemo(() => {
return <DragMenuItemOverlay icon={icon} title={pageTitle} />;
}, [icon, pageTitle]);
@@ -108,33 +125,49 @@ export const FavouriteDocSidebarNavItem = ({
active={linkActive}
to={`/${pageId}`}
linkComponent={WorkbenchLink}
collapsed={collapsible ? collapsed : undefined}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
postfix={
<PostfixItem
docCollection={workspace}
pageId={pageId}
pageTitle={pageTitle}
inFavorites={true}
/>
}
>
<span className={styles.label} data-untitled={untitled}>
{pageTitle}
</span>
<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}>
{referencesToShow.map(id => {
return (
<ReferencePage
key={id}
docCollection={workspace}
pageId={id}
metaMapping={metaMapping}
parentIds={new Set([pageId])}
/>
);
})}
{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>
);

View File

@@ -1,5 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
export const label = style({
selectors: {
'&[data-untitled="true"]': {
@@ -7,6 +7,16 @@ export const label = style({
},
},
});
export const labelContainer = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const labelTooltipContainer = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
export const favItemWrapper = style({
display: 'flex',
flexDirection: 'column',
@@ -22,22 +32,6 @@ export const favItemWrapper = style({
},
},
});
const slideDown = keyframes({
'0%': {
height: '0px',
},
'100%': {
height: 'var(--radix-collapsible-content-height)',
},
});
const slideUp = keyframes({
'0%': {
height: 'var(--radix-collapsible-content-height)',
},
'100%': {
height: '0px',
},
});
export const collapsibleContent = style({
overflow: 'hidden',
marginTop: '4px',
@@ -45,12 +39,6 @@ export const collapsibleContent = style({
'&[data-hidden="true"]': {
display: 'none',
},
'&[data-state="open"]': {
animation: `${slideDown} 0.2s ease-out`,
},
'&[data-state="closed"]': {
animation: `${slideUp} 0.2s ease-out`,
},
},
});
export const collapsibleContentInner = style({
@@ -131,3 +119,11 @@ export const emptyFavouritesMessage = style({
color: cssVar('black30'),
userSelect: 'none',
});
export const noReferences = style({
fontSize: cssVar('fontSm'),
textAlign: 'left',
paddingLeft: '32px',
color: cssVar('black30'),
lineHeight: '30px',
userSelect: 'none',
});

View File

@@ -1,49 +0,0 @@
import type { Doc, DocCollection } from '@blocksuite/store';
import type { Atom } from 'jotai';
import { atom, useAtomValue } from 'jotai';
import { useDocCollectionPage } from './use-block-suite-workspace-page';
const weakMap = new WeakMap<Doc, Atom<string[]>>();
function getPageBacklinks(page: Doc): string[] {
return (
page.collection.indexer.backlink
?.getBacklink(page.id)
.map(linkNode => linkNode.pageId)
.filter(id => id !== page.id) ?? []
);
}
const getPageBacklinksAtom = (page: Doc | null) => {
if (!page) {
return atom([]);
}
if (!weakMap.has(page)) {
const baseAtom = atom<string[]>([]);
baseAtom.onMount = set => {
const disposables = [
page.slots.ready.on(() => {
set(getPageBacklinks(page));
}),
page.collection.indexer.backlink?.slots.indexUpdated.on(() => {
set(getPageBacklinks(page));
}),
];
set(getPageBacklinks(page));
return () => {
disposables.forEach(disposable => disposable?.dispose());
};
};
weakMap.set(page, baseAtom);
}
return weakMap.get(page) as Atom<string[]>;
};
export function useBlockSuitePageBacklinks(
docCollection: DocCollection,
docId: string
): string[] {
const doc = useDocCollectionPage(docCollection, docId);
return useAtomValue(getPageBacklinksAtom(doc));
}

View File

@@ -1,46 +0,0 @@
import type { Doc, DocCollection } from '@blocksuite/store';
import type { Atom } from 'jotai';
import { atom, useAtomValue } from 'jotai';
import { useDocCollectionPage } from './use-block-suite-workspace-page';
const weakMap = new WeakMap<Doc, Atom<string[]>>();
function getPageReferences(page: Doc): string[] {
return Object.values(
page.collection.indexer.backlink?.linkIndexMap[page.id] ?? {}
).flatMap(linkNodes => linkNodes.map(linkNode => linkNode.pageId));
}
const getPageReferencesAtom = (page: Doc | null) => {
if (!page) {
return atom([]);
}
if (!weakMap.has(page)) {
const baseAtom = atom<string[]>([]);
baseAtom.onMount = set => {
const disposables = [
page.slots.ready.on(() => {
set(getPageReferences(page));
}),
page.collection.indexer.backlink?.slots.indexUpdated.on(() => {
set(getPageReferences(page));
}),
];
set(getPageReferences(page));
return () => {
disposables.forEach(disposable => disposable?.dispose());
};
};
weakMap.set(page, baseAtom);
}
return weakMap.get(page) as Atom<string[]>;
};
export function useBlockSuitePageReferences(
docCollection: DocCollection,
pageId: string
): string[] {
const page = useDocCollectionPage(docCollection, pageId);
return useAtomValue(getPageReferencesAtom(page));
}

View File

@@ -0,0 +1,24 @@
import type { DocService } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import type { DocsSearchService } from '../../docs-search';
interface Backlink {
docId: string;
blockId: string;
title: string;
}
export class DocBacklinks extends Entity {
constructor(
private readonly docsSearchService: DocsSearchService,
private readonly docService: DocService
) {
super();
}
backlinks$ = LiveData.from<Backlink[]>(
this.docsSearchService.watchRefsTo(this.docService.doc.id),
[]
);
}

View File

@@ -0,0 +1,23 @@
import type { DocService } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import type { DocsSearchService } from '../../docs-search';
interface Link {
docId: string;
title: string;
}
export class DocLinks extends Entity {
constructor(
private readonly docsSearchService: DocsSearchService,
private readonly docService: DocService
) {
super();
}
links$ = LiveData.from<Link[]>(
this.docsSearchService.watchRefsFrom(this.docService.doc.id),
[]
);
}

View File

@@ -0,0 +1,22 @@
import {
DocScope,
DocService,
type Framework,
WorkspaceScope,
} from '@toeverything/infra';
import { DocsSearchService } from '../docs-search';
import { DocBacklinks } from './entities/doc-backlinks';
import { DocLinks } from './entities/doc-links';
import { DocLinksService } from './services/doc-links';
export { DocLinksService } from './services/doc-links';
export function configureDocLinksModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.scope(DocScope)
.service(DocLinksService)
.entity(DocBacklinks, [DocsSearchService, DocService])
.entity(DocLinks, [DocsSearchService, DocService]);
}

View File

@@ -0,0 +1,9 @@
import { Service } from '@toeverything/infra';
import { DocBacklinks } from '../entities/doc-backlinks';
import { DocLinks } from '../entities/doc-links';
export class DocLinksService extends Service {
backlinks = this.framework.createEntity(DocBacklinks);
links = this.framework.createEntity(DocLinks);
}

View File

@@ -91,6 +91,7 @@ export class DocsIndexer extends Entity {
// jobs should have the same docId, so we just pick the first one
const docId = jobs[0].payload.docId;
const startTime = performance.now();
logger.debug('Start crawling job for docId:', docId);
if (docId) {
@@ -100,6 +101,11 @@ export class DocsIndexer extends Entity {
await this.crawlingDocData(docId);
}
}
const duration = performance.now() - startTime;
logger.debug(
'Finish crawling job for docId:' + docId + ' in ' + duration + 'ms '
);
}
startCrawling() {

View File

@@ -45,7 +45,7 @@ export class DocsSearchService extends Service {
},
{
type: 'boost',
boost: 100,
boost: 1.5,
query: {
type: 'match',
field: 'flavour',
@@ -225,6 +225,195 @@ export class DocsSearchService extends Service {
);
}
async searchRefsFrom(docId: string): Promise<
{
docId: string;
title: string;
}[]
> {
const { nodes } = await this.indexer.blockIndex.search(
{
type: 'boolean',
occur: 'must',
queries: [
{
type: 'match',
field: 'docId',
match: docId,
},
{
type: 'exists',
field: 'ref',
},
],
},
{
fields: ['ref'],
pagination: {
limit: 100,
},
}
);
const docIds = new Set(
nodes.flatMap(node => {
const refs = node.fields.ref;
return typeof refs === 'string' ? [refs] : refs;
})
);
const docData = await this.indexer.docIndex.getAll(Array.from(docIds));
return docData.map(doc => {
const title = doc.get('title');
return {
docId: doc.id,
title: title ? (typeof title === 'string' ? title : title[0]) : '',
};
});
}
watchRefsFrom(docId: string) {
return this.indexer.blockIndex
.search$(
{
type: 'boolean',
occur: 'must',
queries: [
{
type: 'match',
field: 'docId',
match: docId,
},
{
type: 'exists',
field: 'ref',
},
],
},
{
fields: ['ref'],
pagination: {
limit: 100,
},
}
)
.pipe(
switchMap(({ nodes }) => {
return fromPromise(async () => {
const docIds = new Set(
nodes.flatMap(node => {
const refs = node.fields.ref;
return typeof refs === 'string' ? [refs] : refs;
})
);
const docData = await this.indexer.docIndex.getAll(
Array.from(docIds)
);
return docData.map(doc => {
const title = doc.get('title');
return {
docId: doc.id,
title: title
? typeof title === 'string'
? title
: title[0]
: '',
};
});
});
})
);
}
async searchRefsTo(docId: string): Promise<
{
docId: string;
blockId: string;
title: string;
}[]
> {
const { buckets } = await this.indexer.blockIndex.aggregate(
{
type: 'match',
field: 'ref',
match: docId,
},
'docId',
{
hits: {
fields: ['docId', 'blockId'],
pagination: {
limit: 1,
},
},
pagination: {
limit: 100,
},
}
);
const docData = await this.indexer.docIndex.getAll(
buckets.map(bucket => bucket.key)
);
return buckets.map(bucket => {
const title =
docData.find(doc => doc.id === bucket.key)?.get('title') ?? '';
const blockId = bucket.hits.nodes[0]?.fields.blockId ?? '';
return {
docId: bucket.key,
blockId: typeof blockId === 'string' ? blockId : blockId[0],
title: typeof title === 'string' ? title : title[0],
};
});
}
watchRefsTo(docId: string) {
return this.indexer.blockIndex
.aggregate$(
{
type: 'match',
field: 'ref',
match: docId,
},
'docId',
{
hits: {
fields: ['docId', 'blockId'],
pagination: {
limit: 1,
},
},
pagination: {
limit: 100,
},
}
)
.pipe(
switchMap(({ buckets }) => {
return fromPromise(async () => {
const docData = await this.indexer.docIndex.getAll(
buckets.map(bucket => bucket.key)
);
return buckets.map(bucket => {
const title =
docData.find(doc => doc.id === bucket.key)?.get('title') ?? '';
const blockId = bucket.hits.nodes[0]?.fields.blockId ?? '';
return {
docId: bucket.key,
blockId: typeof blockId === 'string' ? blockId : blockId[0],
title: typeof title === 'string' ? title : title[0],
};
});
});
})
);
}
async getDocTitle(docId: string) {
const doc = await this.indexer.docIndex.get(docId);
const title = doc?.get('title');

View File

@@ -3,6 +3,7 @@ import { configureInfraModules, type Framework } from '@toeverything/infra';
import { configureCloudModule } from './cloud';
import { configureCollectionModule } from './collection';
import { configureDocLinksModule } from './doc-link';
import { configureDocsSearchModule } from './docs-search';
import { configureFindInPageModule } from './find-in-page';
import { configureNavigationModule } from './navigation';
@@ -34,6 +35,7 @@ export function configureCommonModules(framework: Framework) {
configurePeekViewModule(framework);
configureQuickSearchModule(framework);
configureDocsSearchModule(framework);
configureDocLinksModule(framework);
}
export function configureImpls(framework: Framework) {

View File

@@ -210,6 +210,8 @@ export class CloudWorkspaceFlavourProviderService
const bs = new DocCollection({
id,
schema: globalBlockSuiteSchema,
disableBacklinkIndex: true,
disableSearchIndex: true,
});
if (localData) applyUpdate(bs.doc, localData);

View File

@@ -142,6 +142,8 @@ export class LocalWorkspaceFlavourProvider
const bs = new DocCollection({
id,
schema: globalBlockSuiteSchema,
disableBacklinkIndex: true,
disableSearchIndex: true,
});
if (localData) applyUpdate(bs.doc, localData);