mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
refactor(core): use new backlink indexer (#7296)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
[]
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
[]
|
||||
);
|
||||
}
|
||||
22
packages/frontend/core/src/modules/doc-link/index.ts
Normal file
22
packages/frontend/core/src/modules/doc-link/index.ts
Normal 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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -210,6 +210,8 @@ export class CloudWorkspaceFlavourProviderService
|
||||
const bs = new DocCollection({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
|
||||
if (localData) applyUpdate(bs.doc, localData);
|
||||
|
||||
@@ -142,6 +142,8 @@ export class LocalWorkspaceFlavourProvider
|
||||
const bs = new DocCollection({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
|
||||
if (localData) applyUpdate(bs.doc, localData);
|
||||
|
||||
Reference in New Issue
Block a user