mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
refactor(core): use new backlink indexer (#7296)
This commit is contained in:
@@ -29,6 +29,23 @@ export class DocRecordList extends Entity {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public readonly trashDocs$ = LiveData.from<DocRecord[]>(
|
||||||
|
this.store.watchTrashDocIds().pipe(
|
||||||
|
map(ids =>
|
||||||
|
ids.map(id => {
|
||||||
|
const exists = this.pool.get(id);
|
||||||
|
if (exists) {
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
const record = this.framework.createEntity(DocRecord, { id });
|
||||||
|
this.pool.set(id, record);
|
||||||
|
return record;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
public readonly isReady$ = LiveData.from(
|
public readonly isReady$ = LiveData.from(
|
||||||
this.store.watchDocListReady(),
|
this.store.watchDocListReady(),
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -58,5 +58,6 @@ export class DocRecord extends Entity<{ id: string }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
title$ = this.meta$.map(meta => meta.title ?? '');
|
title$ = this.meta$.map(meta => meta.title ?? '');
|
||||||
|
|
||||||
trash$ = this.meta$.map(meta => meta.trash ?? false);
|
trash$ = this.meta$.map(meta => meta.trash ?? false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,29 @@ export class DocsStore extends Store {
|
|||||||
return () => {
|
return () => {
|
||||||
dispose();
|
dispose();
|
||||||
};
|
};
|
||||||
}).pipe(distinctUntilChanged((p, c) => isEqual(p, c)));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watchTrashDocIds() {
|
||||||
|
return new Observable<string[]>(subscriber => {
|
||||||
|
const emit = () => {
|
||||||
|
subscriber.next(
|
||||||
|
this.workspaceService.workspace.docCollection.meta.docMetas
|
||||||
|
.map(v => (v.trash ? v.id : null))
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
emit();
|
||||||
|
|
||||||
|
const dispose =
|
||||||
|
this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on(
|
||||||
|
emit
|
||||||
|
).dispose;
|
||||||
|
return () => {
|
||||||
|
dispose();
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
watchDocMeta(id: string) {
|
watchDocMeta(id: string) {
|
||||||
|
|||||||
@@ -233,7 +233,6 @@ export class IndexedDBJobQueue<J> implements JobQueue<J> {
|
|||||||
fromPromise(async () => {
|
fromPromise(async () => {
|
||||||
const trx = this.database.transaction(['jobs'], 'readonly');
|
const trx = this.database.transaction(['jobs'], 'readonly');
|
||||||
const remaining = await trx.objectStore('jobs').count();
|
const remaining = await trx.objectStore('jobs').count();
|
||||||
console.log(remaining);
|
|
||||||
return { remaining };
|
return { remaining };
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { useCurrentWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
|
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 {
|
import type {
|
||||||
PageInfoCustomProperty,
|
PageInfoCustomProperty,
|
||||||
PageInfoCustomPropertyMeta,
|
PageInfoCustomPropertyMeta,
|
||||||
@@ -380,7 +380,7 @@ export const PagePropertiesSettingsPopup = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PageBacklinksPopupProps = PropsWithChildren<{
|
type PageBacklinksPopupProps = PropsWithChildren<{
|
||||||
backlinks: string[];
|
backlinks: { docId: string; blockId: string; title: string }[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const PageBacklinksPopup = ({
|
export const PageBacklinksPopup = ({
|
||||||
@@ -398,11 +398,11 @@ export const PageBacklinksPopup = ({
|
|||||||
}}
|
}}
|
||||||
items={
|
items={
|
||||||
<div className={styles.backlinksList}>
|
<div className={styles.backlinksList}>
|
||||||
{backlinks.map(pageId => (
|
{backlinks.map(link => (
|
||||||
<AffinePageReference
|
<AffinePageReference
|
||||||
key={pageId}
|
key={link.docId + ':' + link.blockId}
|
||||||
wrapper={MenuItem}
|
wrapper={MenuItem}
|
||||||
pageId={pageId}
|
pageId={link.docId}
|
||||||
docCollection={manager.workspace.docCollection}
|
docCollection={manager.workspace.docCollection}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -597,10 +597,11 @@ export const PagePropertiesTableHeader = ({
|
|||||||
const manager = useContext(managerContext);
|
const manager = useContext(managerContext);
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const backlinks = useBlockSuitePageBacklinks(
|
const { docLinksServices } = useServices({
|
||||||
manager.workspace.docCollection,
|
DocLinksServices: DocLinksService,
|
||||||
manager.pageId
|
});
|
||||||
);
|
const docBacklinks = docLinksServices.backlinks;
|
||||||
|
const backlinks = useLiveData(docBacklinks.backlinks$);
|
||||||
|
|
||||||
const { docService, workspaceService } = useServices({
|
const { docService, workspaceService } = useServices({
|
||||||
DocService,
|
DocService,
|
||||||
|
|||||||
+72
@@ -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',
|
||||||
|
});
|
||||||
+77
@@ -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 { PeekViewService } from '@affine/core/modules/peek-view';
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import {
|
import {
|
||||||
BiDirectionalLinkPanel,
|
|
||||||
DocMetaTags,
|
DocMetaTags,
|
||||||
DocTitle,
|
DocTitle,
|
||||||
EdgelessEditor,
|
EdgelessEditor,
|
||||||
@@ -34,6 +33,7 @@ import React, {
|
|||||||
|
|
||||||
import { PagePropertiesTable } from '../../affine/page-properties';
|
import { PagePropertiesTable } from '../../affine/page-properties';
|
||||||
import { AffinePageReference } from '../../affine/reference-link';
|
import { AffinePageReference } from '../../affine/reference-link';
|
||||||
|
import { BiDirectionalLinkPanel } from './bi-directional-link-panel';
|
||||||
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
|
import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
|
||||||
import {
|
import {
|
||||||
patchDocModeService,
|
patchDocModeService,
|
||||||
@@ -65,10 +65,6 @@ const adapted = {
|
|||||||
react: React,
|
react: React,
|
||||||
elementClass: EdgelessEditor,
|
elementClass: EdgelessEditor,
|
||||||
}),
|
}),
|
||||||
BiDirectionalLinkPanel: createReactComponentFromLit({
|
|
||||||
react: React,
|
|
||||||
elementClass: BiDirectionalLinkPanel,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BlocksuiteEditorProps {
|
interface BlocksuiteEditorProps {
|
||||||
@@ -211,9 +207,7 @@ export const BlocksuiteDocEditor = forwardRef<
|
|||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
) : null}
|
) : null}
|
||||||
{docPage && !page.readonly ? (
|
{!page.readonly ? <BiDirectionalLinkPanel /> : null}
|
||||||
<adapted.BiDirectionalLinkPanel doc={page} pageRoot={docPage} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{portals}
|
{portals}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -183,7 +183,10 @@ export const ItemGroup = <T extends ListItem>({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Collapsible.Content className={styles.collapsibleContent}>
|
<Collapsible.Content
|
||||||
|
className={styles.collapsibleContent}
|
||||||
|
data-state={!collapsed ? 'open' : 'closed'}
|
||||||
|
>
|
||||||
<div className={styles.collapsibleContentInner}>
|
<div className={styles.collapsibleContentInner}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<PageListItemRenderer key={item.id} {...item} />
|
<PageListItemRenderer key={item.id} {...item} />
|
||||||
|
|||||||
+2
-8
@@ -231,10 +231,6 @@ export const CollectionSidebarNavItemContent = ({
|
|||||||
() => new Set(collection.allowList),
|
() => new Set(collection.allowList),
|
||||||
[collection.allowList]
|
[collection.allowList]
|
||||||
);
|
);
|
||||||
const allPagesMeta = useMemo(
|
|
||||||
() => Object.fromEntries(pages.map(v => [v.id, v])),
|
|
||||||
[pages]
|
|
||||||
);
|
|
||||||
const removeFromAllowList = useCallback(
|
const removeFromAllowList = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
collectionService.deletePageFromCollection(collection.id, id);
|
collectionService.deletePageFromCollection(collection.id, id);
|
||||||
@@ -259,18 +255,16 @@ export const CollectionSidebarNavItemContent = ({
|
|||||||
filtered.map(page => {
|
filtered.map(page => {
|
||||||
return (
|
return (
|
||||||
<Doc
|
<Doc
|
||||||
|
docId={page.id}
|
||||||
parentId={dndId}
|
parentId={dndId}
|
||||||
inAllowList={allowList.has(page.id)}
|
inAllowList={allowList.has(page.id)}
|
||||||
removeFromAllowList={removeFromAllowList}
|
removeFromAllowList={removeFromAllowList}
|
||||||
allPageMeta={allPagesMeta}
|
|
||||||
doc={page}
|
|
||||||
key={page.id}
|
key={page.id}
|
||||||
docCollection={docCollection}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.emptyCollection}>
|
<div className={styles.noReferences}>
|
||||||
{t['com.affine.collection.emptyCollection']()}
|
{t['com.affine.collection.emptyCollection']()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+76
-38
@@ -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 {
|
import {
|
||||||
WorkbenchLink,
|
WorkbenchLink,
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
} from '@affine/core/modules/workbench';
|
} from '@affine/core/modules/workbench';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||||
import type { DocCollection, DocMeta } from '@blocksuite/store';
|
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
import {
|
||||||
import React, { useMemo } from 'react';
|
DocsService,
|
||||||
|
LiveData,
|
||||||
|
useLiveData,
|
||||||
|
useService,
|
||||||
|
useServices,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type DNDIdentifier,
|
type DNDIdentifier,
|
||||||
@@ -22,42 +28,56 @@ import { ReferencePage } from '../components/reference-page';
|
|||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
export const Doc = ({
|
export const Doc = ({
|
||||||
doc,
|
docId,
|
||||||
parentId,
|
parentId,
|
||||||
docCollection,
|
|
||||||
allPageMeta,
|
|
||||||
inAllowList,
|
inAllowList,
|
||||||
removeFromAllowList,
|
removeFromAllowList,
|
||||||
}: {
|
}: {
|
||||||
parentId: DNDIdentifier;
|
parentId: DNDIdentifier;
|
||||||
doc: DocMeta;
|
docId: string;
|
||||||
inAllowList: boolean;
|
inAllowList: boolean;
|
||||||
removeFromAllowList: (id: string) => void;
|
removeFromAllowList: (id: string) => void;
|
||||||
docCollection: DocCollection;
|
|
||||||
allPageMeta: Record<string, DocMeta>;
|
|
||||||
}) => {
|
}) => {
|
||||||
const [collapsed, setCollapsed] = React.useState(true);
|
const { docsSearchService, workbenchService } = useServices({
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
DocsSearchService,
|
||||||
const location = useLiveData(workbench.location$);
|
WorkbenchService,
|
||||||
|
DocsService,
|
||||||
|
});
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
const location = useLiveData(workbenchService.workbench.location$);
|
||||||
const docId = doc.id;
|
|
||||||
const active = location.pathname === '/' + docId;
|
const active = location.pathname === '/' + docId;
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = React.useState(true);
|
||||||
const docRecord = useLiveData(useService(DocsService).list.doc$(docId));
|
const docRecord = useLiveData(useService(DocsService).list.doc$(docId));
|
||||||
const docMode = useLiveData(docRecord?.mode$);
|
const docMode = useLiveData(docRecord?.mode$);
|
||||||
const dragItemId = getDNDId('collection-list', 'doc', docId, parentId);
|
const docTitle = useLiveData(docRecord?.title$);
|
||||||
|
|
||||||
const icon = useMemo(() => {
|
const icon = useMemo(() => {
|
||||||
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||||
}, [docMode]);
|
}, [docMode]);
|
||||||
|
const references = useLiveData(
|
||||||
const references = useBlockSuitePageReferences(docCollection, docId);
|
useMemo(
|
||||||
const referencesToRender = references.filter(
|
() => LiveData.from(docsSearchService.watchRefsFrom(docId), null),
|
||||||
id => allPageMeta[id] && !allPageMeta[id]?.trash
|
[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(() => {
|
const docTitleElement = useMemo(() => {
|
||||||
return <DragMenuItemOverlay icon={icon} title={docTitle} />;
|
return <DragMenuItemOverlay icon={icon} title={docTitle} />;
|
||||||
}, [icon, docTitle]);
|
}, [icon, docTitle]);
|
||||||
@@ -83,13 +103,12 @@ export const Doc = ({
|
|||||||
linkComponent={WorkbenchLink}
|
linkComponent={WorkbenchLink}
|
||||||
className={styles.title}
|
className={styles.title}
|
||||||
active={active}
|
active={active}
|
||||||
collapsed={referencesToRender.length > 0 ? collapsed : undefined}
|
collapsed={collapsed}
|
||||||
onCollapsedChange={setCollapsed}
|
onCollapsedChange={setCollapsed}
|
||||||
postfix={
|
postfix={
|
||||||
<PostfixItem
|
<PostfixItem
|
||||||
docCollection={docCollection}
|
|
||||||
pageId={docId}
|
pageId={docId}
|
||||||
pageTitle={docTitle}
|
pageTitle={title}
|
||||||
removeFromAllowList={removeFromAllowList}
|
removeFromAllowList={removeFromAllowList}
|
||||||
inAllowList={inAllowList}
|
inAllowList={inAllowList}
|
||||||
/>
|
/>
|
||||||
@@ -98,20 +117,39 @@ export const Doc = ({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...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>
|
</MenuLinkItem>
|
||||||
<Collapsible.Content className={styles.collapsibleContent}>
|
<Collapsible.Content className={styles.collapsibleContent}>
|
||||||
{referencesToRender.map(id => {
|
{references ? (
|
||||||
return (
|
references.length > 0 ? (
|
||||||
<ReferencePage
|
references.map(({ docId: childDocId }) => {
|
||||||
key={id}
|
return (
|
||||||
docCollection={docCollection}
|
<ReferencePage
|
||||||
pageId={id}
|
key={childDocId}
|
||||||
metaMapping={allPageMeta}
|
pageId={childDocId}
|
||||||
parentIds={new Set([docId])}
|
parentIds={new Set([docId])}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
) : (
|
||||||
|
<div className={styles.noReferences}>
|
||||||
|
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
+18
-23
@@ -1,5 +1,5 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
export const wrapper = style({
|
export const wrapper = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -82,22 +82,6 @@ export const menuDividerStyle = style({
|
|||||||
height: '1px',
|
height: '1px',
|
||||||
background: cssVar('borderColor'),
|
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({
|
export const collapsibleContent = style({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginTop: '4px',
|
marginTop: '4px',
|
||||||
@@ -105,14 +89,25 @@ export const collapsibleContent = style({
|
|||||||
'&[data-hidden="true"]': {
|
'&[data-hidden="true"]': {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
'&[data-state="open"]': {
|
},
|
||||||
animation: `${slideDown} 0.2s ease-in-out`,
|
});
|
||||||
},
|
export const label = style({
|
||||||
'&[data-state="closed"]': {
|
selectors: {
|
||||||
animation: `${slideUp} 0.2s ease-in-out`,
|
'&[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({
|
export const emptyCollectionWrapper = style({
|
||||||
padding: '9px 0',
|
padding: '9px 0',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -156,7 +151,7 @@ export const docsListContainer = style({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
});
|
});
|
||||||
export const emptyCollection = style({
|
export const noReferences = style({
|
||||||
fontSize: cssVar('fontSm'),
|
fontSize: cssVar('fontSm'),
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
paddingLeft: '32px',
|
paddingLeft: '32px',
|
||||||
|
|||||||
+10
-6
@@ -5,8 +5,7 @@ import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
|||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
||||||
import type { DocCollection } from '@blocksuite/store';
|
import { useService, useServices, WorkspaceService } from '@toeverything/infra';
|
||||||
import { useService } from '@toeverything/infra';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper';
|
import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper';
|
||||||
@@ -15,7 +14,6 @@ import { OperationItems } from './operation-item';
|
|||||||
|
|
||||||
export type OperationMenuButtonProps = {
|
export type OperationMenuButtonProps = {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
docCollection: DocCollection;
|
|
||||||
pageTitle: string;
|
pageTitle: string;
|
||||||
setRenameModalOpen: () => void;
|
setRenameModalOpen: () => void;
|
||||||
inFavorites?: boolean;
|
inFavorites?: boolean;
|
||||||
@@ -26,7 +24,6 @@ export type OperationMenuButtonProps = {
|
|||||||
|
|
||||||
export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
|
export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
|
||||||
const {
|
const {
|
||||||
docCollection,
|
|
||||||
pageId,
|
pageId,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
setRenameModalOpen,
|
setRenameModalOpen,
|
||||||
@@ -36,8 +33,15 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
|
|||||||
isReferencePage,
|
isReferencePage,
|
||||||
} = props;
|
} = props;
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { createLinkedPage } = usePageHelper(docCollection);
|
const { workspaceService } = useServices({
|
||||||
const { setTrashModal } = useTrashModalHelper(docCollection);
|
WorkspaceService,
|
||||||
|
});
|
||||||
|
const { createLinkedPage } = usePageHelper(
|
||||||
|
workspaceService.workspace.docCollection
|
||||||
|
);
|
||||||
|
const { setTrashModal } = useTrashModalHelper(
|
||||||
|
workspaceService.workspace.docCollection
|
||||||
|
);
|
||||||
|
|
||||||
const favAdapter = useService(FavoriteItemsAdapter);
|
const favAdapter = useService(FavoriteItemsAdapter);
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
|
|||||||
+8
-4
@@ -2,7 +2,7 @@ import { toast } from '@affine/component';
|
|||||||
import { RenameModal } from '@affine/component/rename-modal';
|
import { RenameModal } from '@affine/component/rename-modal';
|
||||||
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import type { DocCollection } from '@blocksuite/store';
|
import { useServices, WorkspaceService } from '@toeverything/infra';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { AddFavouriteButton } from '../favorite/add-favourite-button';
|
import { AddFavouriteButton } from '../favorite/add-favourite-button';
|
||||||
@@ -10,7 +10,6 @@ import * as styles from '../favorite/styles.css';
|
|||||||
import { OperationMenuButton } from './operation-menu-button';
|
import { OperationMenuButton } from './operation-menu-button';
|
||||||
|
|
||||||
type PostfixItemProps = {
|
type PostfixItemProps = {
|
||||||
docCollection: DocCollection;
|
|
||||||
pageId: string;
|
pageId: string;
|
||||||
pageTitle: string;
|
pageTitle: string;
|
||||||
inFavorites?: boolean;
|
inFavorites?: boolean;
|
||||||
@@ -20,10 +19,15 @@ type PostfixItemProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PostfixItem = ({ ...props }: PostfixItemProps) => {
|
export const PostfixItem = ({ ...props }: PostfixItemProps) => {
|
||||||
const { docCollection, pageId, pageTitle } = props;
|
const { pageId, pageTitle } = props;
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { setDocTitle } = useDocMetaHelper(docCollection);
|
const { workspaceService } = useServices({
|
||||||
|
WorkspaceService,
|
||||||
|
});
|
||||||
|
const { setDocTitle } = useDocMetaHelper(
|
||||||
|
workspaceService.workspace.docCollection
|
||||||
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
const handleRename = useCallback(
|
||||||
(newName: string) => {
|
(newName: string) => {
|
||||||
|
|||||||
+80
-56
@@ -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 {
|
import {
|
||||||
WorkbenchLink,
|
WorkbenchLink,
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
} from '@affine/core/modules/workbench';
|
} from '@affine/core/modules/workbench';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||||
import type { DocCollection, DocMeta } from '@blocksuite/store';
|
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
import {
|
||||||
import { useMemo, useState } from 'react';
|
DocsService,
|
||||||
|
LiveData,
|
||||||
|
useLiveData,
|
||||||
|
useServices,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MenuLinkItem } from '../../../app-sidebar';
|
import { MenuLinkItem } from '../../../app-sidebar';
|
||||||
import * as styles from '../favorite/styles.css';
|
import * as styles from '../favorite/styles.css';
|
||||||
import { PostfixItem } from './postfix-item';
|
import { PostfixItem } from './postfix-item';
|
||||||
export interface ReferencePageProps {
|
export interface ReferencePageProps {
|
||||||
docCollection: DocCollection;
|
|
||||||
pageId: string;
|
pageId: string;
|
||||||
metaMapping: Record<string, DocMeta>;
|
|
||||||
parentIds?: Set<string>;
|
parentIds?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReferencePage = ({
|
export const ReferencePage = ({ pageId, parentIds }: ReferencePageProps) => {
|
||||||
docCollection,
|
|
||||||
pageId,
|
|
||||||
metaMapping,
|
|
||||||
parentIds,
|
|
||||||
}: ReferencePageProps) => {
|
|
||||||
const t = useI18n();
|
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 location = useLiveData(workbench.location$);
|
||||||
const active = location.pathname === '/' + pageId;
|
const linkActive = location.pathname === '/' + pageId;
|
||||||
|
const docRecord = useLiveData(docsService.list.doc$(pageId));
|
||||||
const pageRecord = useLiveData(useService(DocsService).list.doc$(pageId));
|
const docMode = useLiveData(docRecord?.mode$);
|
||||||
const pageMode = useLiveData(pageRecord?.mode$);
|
const docTitle = useLiveData(docRecord?.title$);
|
||||||
const icon = useMemo(() => {
|
const icon = useMemo(() => {
|
||||||
return pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||||
}, [pageMode]);
|
}, [docMode]);
|
||||||
|
|
||||||
const references = useBlockSuitePageReferences(docCollection, pageId);
|
|
||||||
const referencesToShow = useMemo(() => {
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
references.filter(ref => metaMapping[ref] && !metaMapping[ref]?.trash)
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}, [references, metaMapping]);
|
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
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 nestedItem = parentIds && parentIds.size > 0;
|
||||||
|
const untitled = !docTitle;
|
||||||
const untitled = !metaMapping[pageId]?.title;
|
const pageTitle = docTitle || t['Untitled']();
|
||||||
const pageTitle = metaMapping[pageId]?.title || t['Untitled']();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible.Root
|
<Collapsible.Root
|
||||||
@@ -62,42 +72,56 @@ export const ReferencePage = ({
|
|||||||
<MenuLinkItem
|
<MenuLinkItem
|
||||||
data-type="reference-page"
|
data-type="reference-page"
|
||||||
data-testid={`reference-page-${pageId}`}
|
data-testid={`reference-page-${pageId}`}
|
||||||
active={active}
|
active={linkActive}
|
||||||
to={`/${pageId}`}
|
to={`/${pageId}`}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
collapsed={collapsible ? collapsed : undefined}
|
collapsed={collapsed}
|
||||||
onCollapsedChange={setCollapsed}
|
onCollapsedChange={setCollapsed}
|
||||||
linkComponent={WorkbenchLink}
|
linkComponent={WorkbenchLink}
|
||||||
postfix={
|
postfix={
|
||||||
<PostfixItem
|
<PostfixItem
|
||||||
docCollection={docCollection}
|
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
pageTitle={pageTitle}
|
pageTitle={pageTitle}
|
||||||
isReferencePage={true}
|
isReferencePage={true}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className={styles.label} data-untitled={untitled}>
|
<div className={styles.labelContainer}>
|
||||||
{pageTitle}
|
<span className={styles.label} data-untitled={untitled}>
|
||||||
</span>
|
{pageTitle}
|
||||||
|
</span>
|
||||||
|
{!collapsed && referencesLoading && (
|
||||||
|
<Tooltip
|
||||||
|
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
||||||
|
>
|
||||||
|
<div className={styles.labelTooltipContainer}>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</MenuLinkItem>
|
</MenuLinkItem>
|
||||||
{collapsible && (
|
<Collapsible.Content className={styles.collapsibleContent}>
|
||||||
<Collapsible.Content className={styles.collapsibleContent}>
|
<div className={styles.collapsibleContentInner}>
|
||||||
<div className={styles.collapsibleContentInner}>
|
{references ? (
|
||||||
{referencesToShow.map(ref => {
|
references.length > 0 ? (
|
||||||
return (
|
references.map(({ docId }) => {
|
||||||
<ReferencePage
|
return (
|
||||||
key={ref}
|
<ReferencePage
|
||||||
docCollection={docCollection}
|
key={docId}
|
||||||
pageId={ref}
|
pageId={docId}
|
||||||
metaMapping={metaMapping}
|
parentIds={new Set([pageId])}
|
||||||
parentIds={new Set([...(parentIds ?? []), pageId])}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
})
|
||||||
})}
|
) : (
|
||||||
</div>
|
<div className={styles.noReferences}>
|
||||||
</Collapsible.Content>
|
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||||
)}
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Collapsible.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-8
@@ -3,21 +3,21 @@ import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
|||||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||||
import { mixpanel } from '@affine/core/utils';
|
import { mixpanel } from '@affine/core/utils';
|
||||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||||
import type { DocCollection } from '@blocksuite/store';
|
import { useService, useServices, WorkspaceService } from '@toeverything/infra';
|
||||||
import { useService } from '@toeverything/infra';
|
|
||||||
|
|
||||||
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
||||||
|
|
||||||
type AddFavouriteButtonProps = {
|
type AddFavouriteButtonProps = {
|
||||||
docCollection: DocCollection;
|
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddFavouriteButton = ({
|
export const AddFavouriteButton = ({ pageId }: AddFavouriteButtonProps) => {
|
||||||
docCollection,
|
const { workspaceService } = useServices({
|
||||||
pageId,
|
WorkspaceService,
|
||||||
}: AddFavouriteButtonProps) => {
|
});
|
||||||
const { createPage, createLinkedPage } = usePageHelper(docCollection);
|
const { createPage, createLinkedPage } = usePageHelper(
|
||||||
|
workspaceService.workspace.docCollection
|
||||||
|
);
|
||||||
const favAdapter = useService(FavoriteItemsAdapter);
|
const favAdapter = useService(FavoriteItemsAdapter);
|
||||||
const handleAddFavorite = useAsyncCallback(
|
const handleAddFavorite = useAsyncCallback(
|
||||||
async e => {
|
async e => {
|
||||||
|
|||||||
+22
-25
@@ -3,18 +3,21 @@ import {
|
|||||||
getDNDId,
|
getDNDId,
|
||||||
resolveDragEndIntent,
|
resolveDragEndIntent,
|
||||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
} 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 { CollectionService } from '@affine/core/modules/collection';
|
||||||
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
|
||||||
import type { WorkspaceFavoriteItem } from '@affine/core/modules/properties/services/schema';
|
import type { WorkspaceFavoriteItem } from '@affine/core/modules/properties/services/schema';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import type { DocMeta } from '@blocksuite/store';
|
|
||||||
import { useDndContext, useDroppable } from '@dnd-kit/core';
|
import { useDndContext, useDroppable } from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} 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 { Fragment, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { CollectionSidebarNavItem } from '../collections';
|
import { CollectionSidebarNavItem } from '../collections';
|
||||||
@@ -25,28 +28,24 @@ import { FavouriteDocSidebarNavItem } from './favourite-nav-item';
|
|||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
|
const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
|
||||||
const metas = useBlockSuiteDocMeta(workspace);
|
const { favoriteItemsAdapter, docsService, collectionService } = useServices({
|
||||||
const favAdapter = useService(FavoriteItemsAdapter);
|
FavoriteItemsAdapter,
|
||||||
const collections = useLiveData(useService(CollectionService).collections$);
|
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 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(
|
const favourites = useLiveData(
|
||||||
favAdapter.orderedFavorites$.map(favs => {
|
favoriteItemsAdapter.orderedFavorites$.map(favs => {
|
||||||
return favs.filter(fav => {
|
return favs.filter(fav => {
|
||||||
if (fav.type === 'doc') {
|
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;
|
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 (
|
return (
|
||||||
<FavouriteDocSidebarNavItem
|
<FavouriteDocSidebarNavItem
|
||||||
metaMapping={docMetaMapping}
|
|
||||||
pageId={item.id}
|
pageId={item.id}
|
||||||
// memo?
|
// memo?
|
||||||
docCollection={workspace}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[collections, docMetaMapping, workspace]
|
[collections, workspace]
|
||||||
);
|
);
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -107,7 +104,7 @@ const FavoriteListInner = ({ docCollection: workspace }: FavoriteListProps) => {
|
|||||||
data-over={shouldRenderDragOver}
|
data-over={shouldRenderDragOver}
|
||||||
>
|
>
|
||||||
<CategoryDivider label={t['com.affine.rootAppSidebar.favorites']()}>
|
<CategoryDivider label={t['com.affine.rootAppSidebar.favorites']()}>
|
||||||
<AddFavouriteButton docCollection={workspace} />
|
<AddFavouriteButton />
|
||||||
</CategoryDivider>
|
</CategoryDivider>
|
||||||
{favourites.map(item => {
|
{favourites.map(item => {
|
||||||
return <Fragment key={item.id}>{renderFavItem(item)}</Fragment>;
|
return <Fragment key={item.id}>{renderFavItem(item)}</Fragment>;
|
||||||
|
|||||||
+71
-38
@@ -1,8 +1,9 @@
|
|||||||
|
import { Loading, Tooltip } from '@affine/component';
|
||||||
import {
|
import {
|
||||||
getDNDId,
|
getDNDId,
|
||||||
parseDNDId,
|
parseDNDId,
|
||||||
} from '@affine/core/hooks/affine/use-global-dnd-helper';
|
} 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 {
|
import {
|
||||||
WorkbenchLink,
|
WorkbenchLink,
|
||||||
WorkbenchService,
|
WorkbenchService,
|
||||||
@@ -12,8 +13,13 @@ import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
|||||||
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
import { type AnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
import { DocsService, useLiveData, useService } from '@toeverything/infra';
|
import {
|
||||||
import { useMemo, useState } from 'react';
|
DocsService,
|
||||||
|
LiveData,
|
||||||
|
useLiveData,
|
||||||
|
useServices,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MenuLinkItem } from '../../../app-sidebar';
|
import { MenuLinkItem } from '../../../app-sidebar';
|
||||||
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
|
||||||
@@ -29,38 +35,49 @@ const animateLayoutChanges: AnimateLayoutChanges = ({
|
|||||||
}) => (isSorting || wasDragging ? false : true);
|
}) => (isSorting || wasDragging ? false : true);
|
||||||
|
|
||||||
export const FavouriteDocSidebarNavItem = ({
|
export const FavouriteDocSidebarNavItem = ({
|
||||||
docCollection: workspace,
|
|
||||||
pageId,
|
pageId,
|
||||||
metaMapping,
|
|
||||||
}: ReferencePageProps & {
|
}: ReferencePageProps & {
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
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 location = useLiveData(workbench.location$);
|
||||||
const linkActive = location.pathname === '/' + pageId;
|
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 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(() => {
|
const icon = useMemo(() => {
|
||||||
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
|
||||||
}, [docMode]);
|
}, [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(() => {
|
const overlayPreview = useMemo(() => {
|
||||||
return <DragMenuItemOverlay icon={icon} title={pageTitle} />;
|
return <DragMenuItemOverlay icon={icon} title={pageTitle} />;
|
||||||
}, [icon, pageTitle]);
|
}, [icon, pageTitle]);
|
||||||
@@ -108,33 +125,49 @@ export const FavouriteDocSidebarNavItem = ({
|
|||||||
active={linkActive}
|
active={linkActive}
|
||||||
to={`/${pageId}`}
|
to={`/${pageId}`}
|
||||||
linkComponent={WorkbenchLink}
|
linkComponent={WorkbenchLink}
|
||||||
collapsed={collapsible ? collapsed : undefined}
|
collapsed={collapsed}
|
||||||
onCollapsedChange={setCollapsed}
|
onCollapsedChange={setCollapsed}
|
||||||
postfix={
|
postfix={
|
||||||
<PostfixItem
|
<PostfixItem
|
||||||
docCollection={workspace}
|
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
pageTitle={pageTitle}
|
pageTitle={pageTitle}
|
||||||
inFavorites={true}
|
inFavorites={true}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className={styles.label} data-untitled={untitled}>
|
<div className={styles.labelContainer}>
|
||||||
{pageTitle}
|
<span className={styles.label} data-untitled={untitled}>
|
||||||
</span>
|
{pageTitle}
|
||||||
|
</span>
|
||||||
|
{!collapsed && referencesLoading && (
|
||||||
|
<Tooltip
|
||||||
|
content={t['com.affine.rootAppSidebar.docs.references-loading']()}
|
||||||
|
>
|
||||||
|
<div className={styles.labelTooltipContainer}>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</MenuLinkItem>
|
</MenuLinkItem>
|
||||||
<Collapsible.Content className={styles.collapsibleContent}>
|
<Collapsible.Content className={styles.collapsibleContent}>
|
||||||
{referencesToShow.map(id => {
|
{references ? (
|
||||||
return (
|
references.length > 0 ? (
|
||||||
<ReferencePage
|
references.map(({ docId }) => {
|
||||||
key={id}
|
return (
|
||||||
docCollection={workspace}
|
<ReferencePage
|
||||||
pageId={id}
|
key={docId}
|
||||||
metaMapping={metaMapping}
|
pageId={docId}
|
||||||
parentIds={new Set([pageId])}
|
parentIds={new Set([pageId])}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
) : (
|
||||||
|
<div className={styles.noReferences}>
|
||||||
|
{t['com.affine.rootAppSidebar.docs.no-subdoc']()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
+19
-23
@@ -1,5 +1,5 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
export const label = style({
|
export const label = style({
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-untitled="true"]': {
|
'&[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({
|
export const favItemWrapper = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
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({
|
export const collapsibleContent = style({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginTop: '4px',
|
marginTop: '4px',
|
||||||
@@ -45,12 +39,6 @@ export const collapsibleContent = style({
|
|||||||
'&[data-hidden="true"]': {
|
'&[data-hidden="true"]': {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
'&[data-state="open"]': {
|
|
||||||
animation: `${slideDown} 0.2s ease-out`,
|
|
||||||
},
|
|
||||||
'&[data-state="closed"]': {
|
|
||||||
animation: `${slideUp} 0.2s ease-out`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
export const collapsibleContentInner = style({
|
export const collapsibleContentInner = style({
|
||||||
@@ -131,3 +119,11 @@ export const emptyFavouritesMessage = style({
|
|||||||
color: cssVar('black30'),
|
color: cssVar('black30'),
|
||||||
userSelect: 'none',
|
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),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
// jobs should have the same docId, so we just pick the first one
|
||||||
const docId = jobs[0].payload.docId;
|
const docId = jobs[0].payload.docId;
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
logger.debug('Start crawling job for docId:', docId);
|
logger.debug('Start crawling job for docId:', docId);
|
||||||
|
|
||||||
if (docId) {
|
if (docId) {
|
||||||
@@ -100,6 +101,11 @@ export class DocsIndexer extends Entity {
|
|||||||
await this.crawlingDocData(docId);
|
await this.crawlingDocData(docId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duration = performance.now() - startTime;
|
||||||
|
logger.debug(
|
||||||
|
'Finish crawling job for docId:' + docId + ' in ' + duration + 'ms '
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
startCrawling() {
|
startCrawling() {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export class DocsSearchService extends Service {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'boost',
|
type: 'boost',
|
||||||
boost: 100,
|
boost: 1.5,
|
||||||
query: {
|
query: {
|
||||||
type: 'match',
|
type: 'match',
|
||||||
field: 'flavour',
|
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) {
|
async getDocTitle(docId: string) {
|
||||||
const doc = await this.indexer.docIndex.get(docId);
|
const doc = await this.indexer.docIndex.get(docId);
|
||||||
const title = doc?.get('title');
|
const title = doc?.get('title');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { configureInfraModules, type Framework } from '@toeverything/infra';
|
|||||||
|
|
||||||
import { configureCloudModule } from './cloud';
|
import { configureCloudModule } from './cloud';
|
||||||
import { configureCollectionModule } from './collection';
|
import { configureCollectionModule } from './collection';
|
||||||
|
import { configureDocLinksModule } from './doc-link';
|
||||||
import { configureDocsSearchModule } from './docs-search';
|
import { configureDocsSearchModule } from './docs-search';
|
||||||
import { configureFindInPageModule } from './find-in-page';
|
import { configureFindInPageModule } from './find-in-page';
|
||||||
import { configureNavigationModule } from './navigation';
|
import { configureNavigationModule } from './navigation';
|
||||||
@@ -34,6 +35,7 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configurePeekViewModule(framework);
|
configurePeekViewModule(framework);
|
||||||
configureQuickSearchModule(framework);
|
configureQuickSearchModule(framework);
|
||||||
configureDocsSearchModule(framework);
|
configureDocsSearchModule(framework);
|
||||||
|
configureDocLinksModule(framework);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureImpls(framework: Framework) {
|
export function configureImpls(framework: Framework) {
|
||||||
|
|||||||
@@ -210,6 +210,8 @@ export class CloudWorkspaceFlavourProviderService
|
|||||||
const bs = new DocCollection({
|
const bs = new DocCollection({
|
||||||
id,
|
id,
|
||||||
schema: globalBlockSuiteSchema,
|
schema: globalBlockSuiteSchema,
|
||||||
|
disableBacklinkIndex: true,
|
||||||
|
disableSearchIndex: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (localData) applyUpdate(bs.doc, localData);
|
if (localData) applyUpdate(bs.doc, localData);
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export class LocalWorkspaceFlavourProvider
|
|||||||
const bs = new DocCollection({
|
const bs = new DocCollection({
|
||||||
id,
|
id,
|
||||||
schema: globalBlockSuiteSchema,
|
schema: globalBlockSuiteSchema,
|
||||||
|
disableBacklinkIndex: true,
|
||||||
|
disableSearchIndex: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (localData) applyUpdate(bs.doc, localData);
|
if (localData) applyUpdate(bs.doc, localData);
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate, transact } from 'yjs';
|
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate, transact } from 'yjs';
|
||||||
|
|
||||||
export function mergeUpdate(updates: Uint8Array[]) {
|
export function mergeUpdate(updates: Uint8Array[]) {
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return new Uint8Array();
|
||||||
|
}
|
||||||
|
if (updates.length === 1) {
|
||||||
|
return updates[0];
|
||||||
|
}
|
||||||
const yDoc = new YDoc();
|
const yDoc = new YDoc();
|
||||||
transact(yDoc, () => {
|
transact(yDoc, () => {
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
|
|||||||
@@ -1082,8 +1082,10 @@
|
|||||||
"com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.",
|
"com.affine.resetSyncStatus.description": "This operation may fix some synchronization issues.",
|
||||||
"com.affine.rootAppSidebar.collections": "Collections",
|
"com.affine.rootAppSidebar.collections": "Collections",
|
||||||
"com.affine.rootAppSidebar.favorites": "Favourites",
|
"com.affine.rootAppSidebar.favorites": "Favourites",
|
||||||
|
"com.affine.rootAppSidebar.docs.no-subdoc": "No linked docs",
|
||||||
"com.affine.rootAppSidebar.favorites.empty": "You can add documents to your favourites",
|
"com.affine.rootAppSidebar.favorites.empty": "You can add documents to your favourites",
|
||||||
"com.affine.rootAppSidebar.others": "Others",
|
"com.affine.rootAppSidebar.others": "Others",
|
||||||
|
"com.affine.rootAppSidebar.docs.references-loading": "Loading linked docs...",
|
||||||
"com.affine.search-tags.placeholder": "Type here ...",
|
"com.affine.search-tags.placeholder": "Type here ...",
|
||||||
"com.affine.selectPage.empty": "Empty",
|
"com.affine.selectPage.empty": "Empty",
|
||||||
"com.affine.selectPage.empty.tips": "No doc titles contain <1>{{search}}</1>",
|
"com.affine.selectPage.empty.tips": "No doc titles contain <1>{{search}}</1>",
|
||||||
|
|||||||
@@ -107,6 +107,23 @@ test("Deleted page's reference will not be shown in sidebar", async ({
|
|||||||
page.locator('.doc-title-container:has-text("Another page")')
|
page.locator('.doc-title-container:has-text("Another page")')
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
|
const anotherPageId = page.url().split('/').reverse()[0];
|
||||||
|
|
||||||
|
const favItemTestId = 'favourite-page-' + newPageId;
|
||||||
|
|
||||||
|
await expect(page.getByTestId(favItemTestId)).toHaveText(
|
||||||
|
'this is a new page to favorite'
|
||||||
|
);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByTestId(favItemTestId)
|
||||||
|
.locator('[data-testid="fav-collapsed-button"]')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const favItemAnotherPageTestId = 'reference-page-' + anotherPageId;
|
||||||
|
|
||||||
|
await expect(page.getByTestId(favItemAnotherPageTestId)).toBeVisible();
|
||||||
|
|
||||||
// delete the page
|
// delete the page
|
||||||
await clickPageMoreActions(page);
|
await clickPageMoreActions(page);
|
||||||
|
|
||||||
@@ -116,18 +133,7 @@ test("Deleted page's reference will not be shown in sidebar", async ({
|
|||||||
// confirm delete
|
// confirm delete
|
||||||
await page.locator('button >> text=Delete').click();
|
await page.locator('button >> text=Delete').click();
|
||||||
|
|
||||||
const favItemTestId = 'favourite-page-' + newPageId;
|
await expect(page.getByTestId(favItemAnotherPageTestId)).toBeVisible();
|
||||||
|
|
||||||
const favoriteListItemInSidebar = page.getByTestId(favItemTestId);
|
|
||||||
expect(await favoriteListItemInSidebar.textContent()).toBe(
|
|
||||||
'this is a new page to favorite'
|
|
||||||
);
|
|
||||||
|
|
||||||
const collapseButton = favoriteListItemInSidebar.locator(
|
|
||||||
'[data-testid="fav-collapsed-button"]'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(collapseButton).toHaveAttribute('data-disabled', 'true');
|
|
||||||
const currentWorkspace = await workspace.current();
|
const currentWorkspace = await workspace.current();
|
||||||
|
|
||||||
expect(currentWorkspace.meta.flavour).toContain('local');
|
expect(currentWorkspace.meta.flavour).toContain('local');
|
||||||
|
|||||||
Reference in New Issue
Block a user