feat: enhance markdown preview for backlinks (#8956)

fix AF-1770
fix AF-1771

---

fix: doc link middlewares
feat: markdown renderer
feat: allow multiple backlink for a single doc
feat: showing correct doc ref link
feat: trim long para & ident lists
feat: list indentition fix
feat: database/latext handling
feat: other block types handling
fix: lint
This commit is contained in:
pengx17
2024-12-02 10:29:44 +00:00
parent b8467a91b8
commit 31bab2507e
19 changed files with 942 additions and 186 deletions

View File

@@ -221,6 +221,7 @@ export const sectionContent = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, gap: 4,
marginTop: 4,
selectors: { selectors: {
'&[hidden]': { '&[hidden]': {
display: 'none', display: 'none',

View File

@@ -1,4 +1,9 @@
import { BlockStdScope, type EditorHost } from '@blocksuite/affine/block-std'; import {
BlockStdScope,
type EditorHost,
type ExtensionType,
ShadowlessElement,
} from '@blocksuite/affine/block-std';
import type { import type {
AffineAIPanelState, AffineAIPanelState,
AffineAIPanelWidgetConfig, AffineAIPanelWidgetConfig,
@@ -10,8 +15,14 @@ import {
ParagraphBlockComponent, ParagraphBlockComponent,
} from '@blocksuite/affine/blocks'; } from '@blocksuite/affine/blocks';
import { WithDisposable } from '@blocksuite/affine/global/utils'; import { WithDisposable } from '@blocksuite/affine/global/utils';
import { BlockViewType, type Doc, type Query } from '@blocksuite/affine/store'; import {
import { css, html, LitElement, nothing, type PropertyValues } from 'lit'; BlockViewType,
type Doc,
type JobMiddleware,
type Query,
type Schema,
} from '@blocksuite/affine/store';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query } from 'lit/decorators.js'; import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { keyed } from 'lit/directives/keyed.js'; import { keyed } from 'lit/directives/keyed.js';
@@ -70,9 +81,12 @@ const customHeadingStyles = css`
export type TextRendererOptions = { export type TextRendererOptions = {
maxHeight?: number; maxHeight?: number;
customHeading?: boolean; customHeading?: boolean;
extensions?: ExtensionType[];
additionalMiddlewares?: JobMiddleware[];
}; };
export class TextRenderer extends WithDisposable(LitElement) { // todo: refactor it for more general purpose usage instead of AI only?
export class TextRenderer extends WithDisposable(ShadowlessElement) {
static override styles = css` static override styles = css`
.ai-answer-text-editor.affine-page-viewport { .ai-answer-text-editor.affine-page-viewport {
background: transparent; background: transparent;
@@ -177,8 +191,9 @@ export class TextRenderer extends WithDisposable(LitElement) {
if (this._answers.length > 0) { if (this._answers.length > 0) {
const latestAnswer = this._answers.pop(); const latestAnswer = this._answers.pop();
this._answers = []; this._answers = [];
if (latestAnswer) { const schema = this.schema ?? this.host?.std.doc.collection.schema;
markDownToDoc(this.host, latestAnswer) if (latestAnswer && schema) {
markDownToDoc(schema, latestAnswer, this.options.additionalMiddlewares)
.then(doc => { .then(doc => {
this._doc = doc.blockCollection.getDoc({ this._doc = doc.blockCollection.getDoc({
query: this._query, query: this._query,
@@ -245,7 +260,7 @@ export class TextRenderer extends WithDisposable(LitElement) {
html`<div class="ai-answer-text-editor affine-page-viewport"> html`<div class="ai-answer-text-editor affine-page-viewport">
${new BlockStdScope({ ${new BlockStdScope({
doc: this._doc, doc: this._doc,
extensions: CustomPageEditorBlockSpecs, extensions: this.options.extensions ?? CustomPageEditorBlockSpecs,
}).render()} }).render()}
</div>` </div>`
)} )}
@@ -277,7 +292,10 @@ export class TextRenderer extends WithDisposable(LitElement) {
accessor answer!: string; accessor answer!: string;
@property({ attribute: false }) @property({ attribute: false })
accessor host!: EditorHost; accessor host: EditorHost | null = null;
@property({ attribute: false })
accessor schema: Schema | null = null;
@property({ attribute: false }) @property({ attribute: false })
accessor options!: TextRendererOptions; accessor options!: TextRendererOptions;

View File

@@ -12,6 +12,7 @@ import {
PlainTextAdapter, PlainTextAdapter,
titleMiddleware, titleMiddleware,
} from '@blocksuite/affine/blocks'; } from '@blocksuite/affine/blocks';
import type { JobMiddleware, Schema } from '@blocksuite/affine/store';
import { DocCollection, Job } from '@blocksuite/affine/store'; import { DocCollection, Job } from '@blocksuite/affine/store';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import type { import type {
@@ -184,16 +185,23 @@ export async function replaceFromMarkdown(
await job.snapshotToSlice(snapshot, host.doc, parent, index); await job.snapshotToSlice(snapshot, host.doc, parent, index);
} }
export async function markDownToDoc(host: EditorHost, answer: string) { export async function markDownToDoc(
const schema = host.std.doc.collection.schema; schema: Schema,
answer: string,
additionalMiddlewares?: JobMiddleware[]
) {
// Should not create a new doc in the original collection // Should not create a new doc in the original collection
const collection = new DocCollection({ const collection = new DocCollection({
schema, schema,
}); });
collection.meta.initialize(); collection.meta.initialize();
const middlewares = [defaultImageProxyMiddleware];
if (additionalMiddlewares) {
middlewares.push(...additionalMiddlewares);
}
const job = new Job({ const job = new Job({
collection, collection,
middlewares: [defaultImageProxyMiddleware], middlewares,
}); });
const mdAdapter = new MarkdownAdapter(job); const mdAdapter = new MarkdownAdapter(job);
const doc = await mdAdapter.toDoc({ const doc = await mdAdapter.toDoc({

View File

@@ -142,7 +142,7 @@ export const copyTextAnswer = async (panel: AffineAIPanelWidget) => {
}; };
export const copyText = async (host: EditorHost, text: string) => { export const copyText = async (host: EditorHost, text: string) => {
const previewDoc = await markDownToDoc(host, text); const previewDoc = await markDownToDoc(host.std.doc.schema, text);
const models = previewDoc const models = previewDoc
.getBlocksByFlavour('affine:note') .getBlocksByFlavour('affine:note')
.map(b => b.model) .map(b => b.model)

View File

@@ -0,0 +1 @@
export * from './_common/components/text-renderer';

View File

@@ -7,7 +7,12 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import type { DocMode } from '@blocksuite/affine/blocks'; import type { DocMode } from '@blocksuite/affine/blocks';
import type { DocCollection } from '@blocksuite/affine/store'; import type { DocCollection } from '@blocksuite/affine/store';
import { LiveData, useLiveData, useService } from '@toeverything/infra'; import {
DocsService,
LiveData,
useLiveData,
useService,
} from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { import {
@@ -36,6 +41,7 @@ function AffinePageReferenceInner({
Icon: UserIcon, Icon: UserIcon,
}: AffinePageReferenceProps) { }: AffinePageReferenceProps) {
const docDisplayMetaService = useService(DocDisplayMetaService); const docDisplayMetaService = useService(DocDisplayMetaService);
const docsService = useService(DocsService);
const i18n = useI18n(); const i18n = useI18n();
let linkWithMode: DocMode | null = null; let linkWithMode: DocMode | null = null;
@@ -62,15 +68,19 @@ function AffinePageReferenceInner({
); );
}) })
); );
const title = useLiveData( const notFound = !useLiveData(docsService.list.doc$(pageId));
let title = useLiveData(
docDisplayMetaService.title$(pageId, { reference: true }) docDisplayMetaService.title$(pageId, { reference: true })
); );
title = notFound ? i18n.t('com.affine.notFoundPage.title') : title;
return ( return (
<> <span className={notFound ? styles.notFound : ''}>
<Icon className={styles.pageReferenceIcon} /> <Icon className={styles.pageReferenceIcon} />
<span className="affine-reference-title">{i18n.t(title)}</span> <span className="affine-reference-title">{i18n.t(title)}</span>
</> </span>
); );
} }

View File

@@ -1,9 +1,11 @@
import { style } from '@vanilla-extract/css'; import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const pageReferenceIcon = style({ export const pageReferenceIcon = style({
verticalAlign: 'middle', verticalAlign: 'middle',
fontSize: '1.1em', fontSize: '1.1em',
transform: 'translate(2px, -1px)', transform: 'translate(2px, -1px)',
color: cssVarV2('icon/primary'),
}); });
export const pageReferenceLink = style({ export const pageReferenceLink = style({
@@ -12,3 +14,13 @@ export const pageReferenceLink = style({
wordBreak: 'break-word', wordBreak: 'break-word',
hyphens: 'auto', hyphens: 'auto',
}); });
export const notFound = style({
color: cssVarV2('text/secondary'),
textDecoration: 'line-through',
});
globalStyle('affine-reference .affine-reference', {
color: 'inherit !important',
textDecoration: 'none !important',
});

View File

@@ -1,5 +1,6 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css'; import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const container = style({ export const container = style({
width: '100%', width: '100%',
@@ -49,7 +50,6 @@ export const title = style({
}); });
export const showButton = style({ export const showButton = style({
width: '56px',
height: '28px', height: '28px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid ' + cssVar('--affine-border-color'), border: '1px solid ' + cssVar('--affine-border-color'),
@@ -74,9 +74,45 @@ export const linksTitles = style({
export const link = style({ export const link = style({
width: '100%', width: '100%',
height: '32px', height: '30px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '4px', gap: '4px',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}); });
globalStyle(`${link} .affine-reference-title`, {
borderBottom: 'none',
});
export const linkPreviewContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const linkPreview = style({
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
borderRadius: '8px',
padding: '8px',
color: cssVarV2('text/primary'),
':hover': {
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
},
});
export const linkPreviewRenderer = style({
cursor: 'pointer',
});
export const collapsedIcon = style({
transition: 'all 0.2s ease-in-out',
color: cssVarV2('icon/primary'),
fontSize: 20,
selectors: {
'&[data-collapsed="true"]': {
transform: 'rotate(90deg)',
color: cssVarV2('icon/secondary'),
},
},
});

View File

@@ -1,32 +1,179 @@
import {
Button,
createReactComponentFromLit,
useLitPortalFactory,
} from '@affine/component';
import { TextRenderer } from '@affine/core/blocksuite/presets';
import { import {
type Backlink, type Backlink,
DocLinksService, DocLinksService,
type Link, type Link,
} from '@affine/core/modules/doc-link'; } from '@affine/core/modules/doc-link';
import { toURLSearchParams } from '@affine/core/modules/navigation';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { LiveData, useLiveData, useServices } from '@toeverything/infra'; import type { JobMiddleware } from '@blocksuite/affine/store';
import { Fragment, useCallback, useState } from 'react'; import { ToggleExpandIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import {
getAFFiNEWorkspaceSchema,
LiveData,
useFramework,
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import React, {
Fragment,
type ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
import { AffinePageReference } from '../../affine/reference-link'; import {
AffinePageReference,
AffineSharedPageReference,
} from '../../affine/reference-link';
import * as styles from './bi-directional-link-panel.css'; import * as styles from './bi-directional-link-panel.css';
import {
patchReferenceRenderer,
type ReferenceReactRenderer,
} from './specs/custom/spec-patchers';
import { createPageModeSpecs } from './specs/page';
const BlocksuiteTextRenderer = createReactComponentFromLit({
react: React,
elementClass: TextRenderer,
});
const CollapsibleSection = ({
title,
children,
length,
}: {
title: ReactNode;
children: ReactNode;
length?: number;
}) => {
const [open, setOpen] = useState(false);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger className={styles.link}>
{title}
{length ? (
<ToggleExpandIcon
className={styles.collapsedIcon}
data-collapsed={!open}
/>
) : null}
</Collapsible.Trigger>
<Collapsible.Content>{children}</Collapsible.Content>
</Collapsible.Root>
);
};
const usePreviewExtensions = () => {
const [reactToLit, portals] = useLitPortalFactory();
const framework = useFramework();
const { workspaceService } = useServices({
WorkspaceService,
});
const referenceRenderer: ReferenceReactRenderer = useMemo(() => {
return function customReference(reference) {
const data = reference.delta.attributes?.reference;
if (!data) return <span />;
const pageId = data.pageId;
if (!pageId) return <span />;
const params = toURLSearchParams(data.params);
if (workspaceService.workspace.openOptions.isSharedMode) {
return (
<AffineSharedPageReference
docCollection={workspaceService.workspace.docCollection}
pageId={pageId}
params={params}
/>
);
}
return <AffinePageReference pageId={pageId} params={params} />;
};
}, [workspaceService]);
const extensions = useMemo(() => {
const specs = createPageModeSpecs(framework);
return [patchReferenceRenderer(reactToLit, referenceRenderer), ...specs];
}, [reactToLit, referenceRenderer, framework]);
return [extensions, portals] as const;
};
export const BiDirectionalLinkPanel = () => { export const BiDirectionalLinkPanel = () => {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const { docLinksService } = useServices({ const { docLinksService, workspaceService } = useServices({
DocLinksService, DocLinksService,
WorkspaceService,
}); });
const [extensions, portals] = usePreviewExtensions();
const t = useI18n(); const t = useI18n();
const links = useLiveData( const links = useLiveData(
show ? docLinksService.links.links$ : new LiveData([] as Link[]) show ? docLinksService.links.links$ : new LiveData([] as Link[])
); );
const backlinks = useLiveData( const backlinkGroups = useLiveData(
show ? docLinksService.backlinks.backlinks$ : new LiveData([] as Backlink[]) LiveData.computed(get => {
if (!show) {
return [];
}
const links = get(docLinksService.backlinks.backlinks$);
// group by docId
const groupedLinks = links.reduce(
(acc, link) => {
acc[link.docId] = [...(acc[link.docId] || []), link];
return acc;
},
{} as Record<string, Backlink[]>
);
return Object.entries(groupedLinks).map(([docId, links]) => ({
docId,
title: links[0].title, // title should be the same for all blocks
links,
}));
})
); );
const backlinkCount = useMemo(() => {
return backlinkGroups.reduce((acc, link) => acc + link.links.length, 0);
}, [backlinkGroups]);
const handleClickShow = useCallback(() => { const handleClickShow = useCallback(() => {
setShow(!show); setShow(!show);
}, [show]); }, [show]);
const textRendererOptions = useMemo(() => {
const docLinkBaseURLMiddleware: JobMiddleware = ({ adapterConfigs }) => {
adapterConfigs.set(
'docLinkBaseUrl',
`/workspace/${workspaceService.workspace.id}`
);
};
return {
customHeading: true,
extensions,
additionalMiddlewares: [docLinkBaseURLMiddleware],
};
}, [extensions, workspaceService.workspace.id]);
return ( return (
<div className={styles.container}> <div className={styles.container}>
{!show && ( {!show && (
@@ -37,9 +184,11 @@ export const BiDirectionalLinkPanel = () => {
<div className={styles.titleLine}> <div className={styles.titleLine}>
<div className={styles.title}>Bi-Directional Links</div> <div className={styles.title}>Bi-Directional Links</div>
<div className={styles.showButton} onClick={handleClickShow}> <Button className={styles.showButton} onClick={handleClickShow}>
{show ? 'Hide' : 'Show'} {show
</div> ? t['com.affine.editor.bi-directional-link-panel.hide']()
: t['com.affine.editor.bi-directional-link-panel.show']()}
</Button>
</div> </div>
{show && ( {show && (
@@ -49,17 +198,78 @@ export const BiDirectionalLinkPanel = () => {
</div> </div>
<div className={styles.linksContainer}> <div className={styles.linksContainer}>
<div className={styles.linksTitles}> <div className={styles.linksTitles}>
{t['com.affine.page-properties.backlinks']()} · {backlinks.length} {t['com.affine.page-properties.backlinks']()} · {backlinkCount}
</div> </div>
{backlinks.map(link => ( {backlinkGroups.map(linkGroup => (
<Fragment key={link.docId}> <CollapsibleSection
<div className={styles.link}> key={linkGroup.docId}
<AffinePageReference key={link.docId} pageId={link.docId} /> title={<AffinePageReference pageId={linkGroup.docId} />}
length={linkGroup.links.length}
>
<div className={styles.linkPreviewContainer}>
{linkGroup.links.map(link => {
if (!link.markdownPreview) {
return null;
}
const searchParams = new URLSearchParams();
const displayMode = link.displayMode || 'page';
searchParams.set('mode', displayMode);
let blockId = link.blockId;
if (
link.parentFlavour === 'affine:database' &&
link.parentBlockId
) {
// if parentBlockFlavour is 'affine:database',
// we will fallback to the database block instead
blockId = link.parentBlockId;
} else if (displayMode === 'edgeless' && link.noteBlockId) {
// if note has displayMode === 'edgeless' && has noteBlockId,
// set noteBlockId as blockId
blockId = link.noteBlockId;
}
searchParams.set('blockIds', blockId);
const to = {
pathname: '/' + linkGroup.docId,
search: '?' + searchParams.toString(),
hash: '',
};
// if this backlink has no noteBlock && displayMode is edgeless, we will render
// the link as a page link
const edgelessLink =
displayMode === 'edgeless' && !link.noteBlockId;
return (
<WorkbenchLink
to={to}
key={link.blockId}
className={styles.linkPreview}
>
{edgelessLink ? (
<>
[Edgeless]
<AffinePageReference
key={link.blockId}
pageId={linkGroup.docId}
params={searchParams}
/>
</>
) : (
<BlocksuiteTextRenderer
className={styles.linkPreviewRenderer}
answer={link.markdownPreview}
schema={getAFFiNEWorkspaceSchema()}
options={textRendererOptions}
/>
)}
</WorkbenchLink>
);
})}
</div> </div>
<br /> </CollapsibleSection>
<pre style={{ opacity: 0.5 }}>{link.markdownPreview}</pre>
<br />
</Fragment>
))} ))}
</div> </div>
<div className={styles.linksContainer}> <div className={styles.linksContainer}>
@@ -78,6 +288,13 @@ export const BiDirectionalLinkPanel = () => {
</div> </div>
</> </>
)} )}
{
<>
{portals.map(p => (
<Fragment key={p.id}>{p.portal}</Fragment>
))}
</>
}
</div> </div>
); );
}; };

View File

@@ -48,11 +48,23 @@ export const InfoTable = ({
[docId, docsSearchService] [docId, docsSearchService]
) )
); );
const backlinks = useLiveData( const backlinks = useLiveData(
useMemo( useMemo(() => {
() => LiveData.from(docsSearchService.watchRefsTo(docId), null), return LiveData.from(docsSearchService.watchRefsTo(docId), []).map(
[docId, docsSearchService] links => {
) const visitedDoc = new Set<string>();
// for each doc, we only show the first block
return links.filter(link => {
if (visitedDoc.has(link.docId)) {
return false;
}
visitedDoc.add(link.docId);
return true;
});
}
);
}, [docId, docsSearchService])
); );
const onBacklinkPropertyChange = useCallback( const onBacklinkPropertyChange = useCallback(

View File

@@ -47,10 +47,21 @@ export const DocInfoSheet = ({
) )
); );
const backlinks = useLiveData( const backlinks = useLiveData(
useMemo( useMemo(() => {
() => LiveData.from(docsSearchService.watchRefsTo(docId), null), return LiveData.from(docsSearchService.watchRefsTo(docId), []).map(
[docId, docsSearchService] links => {
) const visitedDoc = new Set<string>();
// for each doc, we only show the first block
return links.filter(link => {
if (visitedDoc.has(link.docId)) {
return false;
}
visitedDoc.add(link.docId);
return true;
});
}
);
}, [docId, docsSearchService])
); );
const [newPropertyId, setNewPropertyId] = useState<string | null>(null); const [newPropertyId, setNewPropertyId] = useState<string | null>(null);

View File

@@ -7,6 +7,10 @@ export interface Backlink {
docId: string; docId: string;
blockId: string; blockId: string;
title: string; title: string;
noteBlockId?: string;
displayMode?: string;
parentBlockId?: string;
parentFlavour?: string;
markdownPreview?: string; markdownPreview?: string;
} }

View File

@@ -146,6 +146,7 @@ export class DocsIndexer extends Entity {
allIndexedDocs, allIndexedDocs,
rootDocBuffer, rootDocBuffer,
reindexAll: isUpgrade, reindexAll: isUpgrade,
rootDocId: this.workspaceId,
}); });
} else { } else {
const rootDocBuffer = const rootDocBuffer =
@@ -167,6 +168,7 @@ export class DocsIndexer extends Entity {
docBuffer, docBuffer,
storageDocId, storageDocId,
rootDocBuffer, rootDocBuffer,
rootDocId: this.workspaceId,
}); });
} }

View File

@@ -26,7 +26,7 @@ export const blockIndexSchema = defineSchema({
// parent block id // parent block id
parentBlockId: 'String', parentBlockId: 'String',
// additional info // additional info
// { "databaseName": "xxx" } // { "databaseName": "xxx", "displayMode": "page/edgeless", "noteBlockId": "xxx" }
additional: { type: 'String', index: false }, additional: { type: 'String', index: false },
markdownPreview: { type: 'String', index: false }, markdownPreview: { type: 'String', index: false },
}); });

View File

@@ -478,9 +478,16 @@ export class DocsSearchService extends Service {
'docId', 'docId',
{ {
hits: { hits: {
fields: ['docId', 'blockId', 'markdownPreview'], fields: [
'docId',
'blockId',
'parentBlockId',
'parentFlavour',
'additional',
'markdownPreview',
],
pagination: { pagination: {
limit: 1, limit: 5, // the max number of backlinks to show for each doc
}, },
}, },
pagination: { pagination: {
@@ -495,21 +502,60 @@ export class DocsSearchService extends Service {
buckets.map(bucket => bucket.key) buckets.map(bucket => bucket.key)
); );
return buckets.map(bucket => { return buckets.flatMap(bucket => {
const title = const title =
docData.find(doc => doc.id === bucket.key)?.get('title') ?? ''; docData.find(doc => doc.id === bucket.key)?.get('title') ?? '';
const blockId = bucket.hits.nodes[0]?.fields.blockId ?? '';
const markdownPreview = return bucket.hits.nodes.map(node => {
bucket.hits.nodes[0]?.fields.markdownPreview ?? ''; const blockId = node.fields.blockId ?? '';
return { const markdownPreview = node.fields.markdownPreview ?? '';
docId: bucket.key, const additional =
blockId: typeof blockId === 'string' ? blockId : blockId[0], typeof node.fields.additional === 'string'
title: typeof title === 'string' ? title : title[0], ? node.fields.additional
markdownPreview: : node.fields.additional[0];
typeof markdownPreview === 'string'
? markdownPreview const additionalData: {
: markdownPreview[0], displayMode?: string;
}; noteBlockId?: string;
} = JSON.parse(additional || '{}');
const displayMode = additionalData.displayMode ?? '';
const noteBlockId = additionalData.noteBlockId ?? '';
const parentBlockId =
typeof node.fields.parentBlockId === 'string'
? node.fields.parentBlockId
: node.fields.parentBlockId[0];
const parentFlavour =
typeof node.fields.parentFlavour === 'string'
? node.fields.parentFlavour
: node.fields.parentFlavour[0];
return {
docId: bucket.key,
blockId: typeof blockId === 'string' ? blockId : blockId[0],
title: typeof title === 'string' ? title : title[0],
markdownPreview:
typeof markdownPreview === 'string'
? markdownPreview
: markdownPreview[0],
displayMode:
typeof displayMode === 'string'
? displayMode
: displayMode[0],
noteBlockId:
typeof noteBlockId === 'string'
? noteBlockId
: noteBlockId[0],
parentBlockId:
typeof parentBlockId === 'string'
? parentBlockId
: parentBlockId[0],
parentFlavour:
typeof parentFlavour === 'string'
? parentFlavour
: parentFlavour[0],
};
});
}); });
}); });
}) })

View File

@@ -1,12 +1,17 @@
import { import type {
type AffineTextAttributes, AffineTextAttributes,
MarkdownAdapter, AttachmentBlockModel,
BookmarkBlockModel,
EmbedBlockModel,
ImageBlockModel,
} from '@blocksuite/affine/blocks'; } from '@blocksuite/affine/blocks';
import { MarkdownAdapter } from '@blocksuite/affine/blocks';
import { import {
createYProxy, createYProxy,
DocCollection, DocCollection,
type DraftModel, type DraftModel,
Job, Job,
type JobMiddleware,
type YBlock, type YBlock,
} from '@blocksuite/affine/store'; } from '@blocksuite/affine/store';
import type { DeltaInsert } from '@blocksuite/inline'; import type { DeltaInsert } from '@blocksuite/inline';
@@ -73,42 +78,6 @@ async function getOrCreateCachedYDoc(data: Uint8Array) {
} }
} }
function yblockToDraftModal(yblock: YBlock): DraftModel | null {
const flavour = yblock.get('sys:flavour');
const blockSchema = blocksuiteSchema.flavourSchemaMap.get(flavour);
if (!blockSchema) {
return null;
}
const keys = Array.from(yblock.keys())
.filter(key => key.startsWith('prop:'))
.map(key => key.substring(5));
const props = Object.fromEntries(
keys.map(key => [key, createYProxy(yblock.get(`prop:${key}`))])
);
return {
...props,
id: yblock.get('sys:id'),
flavour,
children: [],
role: blockSchema.model.role,
version: (yblock.get('sys:version') as number) ?? blockSchema.version,
keys: Array.from(yblock.keys())
.filter(key => key.startsWith('prop:'))
.map(key => key.substring(5)),
};
}
const markdownAdapter = new MarkdownAdapter(
new Job({
collection: new DocCollection({
id: 'indexer',
schema: blocksuiteSchema,
}),
})
);
interface BlockDocumentInfo { interface BlockDocumentInfo {
docId: string; docId: string;
blockId: string; blockId: string;
@@ -119,50 +88,364 @@ interface BlockDocumentInfo {
ref?: string[]; ref?: string[];
parentFlavour?: string; parentFlavour?: string;
parentBlockId?: string; parentBlockId?: string;
additional?: { databaseName?: string }; additional?: {
databaseName?: string;
displayMode?: string;
noteBlockId?: string;
};
yblock: YMap<any>; yblock: YMap<any>;
markdownPreview?: string; markdownPreview?: string;
} }
const markdownPreviewCache = new WeakMap<BlockDocumentInfo, string | null>(); const bookmarkFlavours = new Set([
const generateMarkdownPreview = async (block: BlockDocumentInfo) => { 'affine:bookmark',
if (markdownPreviewCache.has(block)) { 'affine:embed-youtube',
return markdownPreviewCache.get(block); 'affine:embed-figma',
'affine:embed-github',
'affine:embed-loom',
]);
function generateMarkdownPreviewBuilder(
yRootDoc: YDoc,
workspaceId: string,
blocks: BlockDocumentInfo[]
) {
function yblockToDraftModal(yblock: YBlock): DraftModel | null {
const flavour = yblock.get('sys:flavour');
const blockSchema = blocksuiteSchema.flavourSchemaMap.get(flavour);
if (!blockSchema) {
return null;
}
const keys = Array.from(yblock.keys())
.filter(key => key.startsWith('prop:'))
.map(key => key.substring(5));
const props = Object.fromEntries(
keys.map(key => [key, createYProxy(yblock.get(`prop:${key}`))])
);
return {
...props,
id: yblock.get('sys:id'),
flavour,
children: [],
role: blockSchema.model.role,
version: (yblock.get('sys:version') as number) ?? blockSchema.version,
keys: Array.from(yblock.keys())
.filter(key => key.startsWith('prop:'))
.map(key => key.substring(5)),
};
} }
const flavour = block.flavour;
let markdown: string | null = null; const titleMiddleware: JobMiddleware = ({ adapterConfigs }) => {
if ( const pages = yRootDoc.getMap('meta').get('pages');
flavour === 'affine:paragraph' || if (!(pages instanceof YArray)) {
flavour === 'affine:list' || return;
flavour === 'affine:code' }
) { for (const meta of pages.toArray()) {
adapterConfigs.set(
'title:' + meta.get('id'),
meta.get('title')?.toString() ?? 'Untitled'
);
}
};
const baseUrl = `/workspace/${workspaceId}`;
function getDocLink(docId: string, blockId: string) {
const searchParams = new URLSearchParams();
searchParams.set('blockIds', blockId);
return `${baseUrl}/${docId}?${searchParams.toString()}`;
}
const docLinkBaseURLMiddleware: JobMiddleware = ({ adapterConfigs }) => {
adapterConfigs.set('docLinkBaseUrl', baseUrl);
};
const markdownAdapter = new MarkdownAdapter(
new Job({
collection: new DocCollection({
id: 'indexer',
schema: blocksuiteSchema,
}),
middlewares: [docLinkBaseURLMiddleware, titleMiddleware],
})
);
const markdownPreviewCache = new WeakMap<BlockDocumentInfo, string | null>();
function trimCodeBlock(markdown: string) {
const lines = markdown.split('\n').filter(line => line.trim() !== '');
if (lines.length > 5) {
return [...lines.slice(0, 4), '...', lines.at(-1), ''].join('\n');
}
return [...lines, ''].join('\n');
}
function trimParagraph(markdown: string) {
const lines = markdown.split('\n').filter(line => line.trim() !== '');
if (lines.length > 3) {
return [...lines.slice(0, 3), '...', lines.at(-1), ''].join('\n');
}
return [...lines, ''].join('\n');
}
function getListDepth(block: BlockDocumentInfo) {
let parentBlockCount = 0;
let currentBlock: BlockDocumentInfo | undefined = block;
do {
currentBlock = blocks.find(
b => b.blockId === currentBlock?.parentBlockId
);
// reach the root block. do not count it.
if (!currentBlock || currentBlock.flavour !== 'affine:list') {
break;
}
parentBlockCount++;
} while (currentBlock);
return parentBlockCount;
}
// only works for list block
function indentMarkdown(markdown: string, depth: number) {
if (depth <= 0) {
return markdown;
}
return (
markdown
.split('\n')
.map(line => ' '.repeat(depth) + line)
.join('\n') + '\n'
);
}
const generateDatabaseMarkdownPreview = (block: BlockDocumentInfo) => {
const isDatabaseBlock = (block: BlockDocumentInfo) => {
return block.flavour === 'affine:database';
};
const model = yblockToDraftModal(block.yblock);
if (!model) {
return null;
}
let dbBlock: BlockDocumentInfo | null = null;
if (isDatabaseBlock(block)) {
dbBlock = block;
} else {
const parentBlock = blocks.find(b => b.blockId === block.parentBlockId);
if (parentBlock && isDatabaseBlock(parentBlock)) {
dbBlock = parentBlock;
}
}
if (!dbBlock) {
return null;
}
const url = getDocLink(block.docId, dbBlock.blockId);
const title = dbBlock.additional?.databaseName;
return `[database · ${title || 'Untitled'}][](${url})\n`;
};
const generateImageMarkdownPreview = (block: BlockDocumentInfo) => {
const isImageModel = (
model: DraftModel | null
): model is DraftModel<ImageBlockModel> => {
return model?.flavour === 'affine:image';
};
const model = yblockToDraftModal(block.yblock);
if (!isImageModel(model)) {
return null;
}
const info = ['an image block'];
if (model.sourceId) {
info.push(`file id ${model.sourceId}`);
}
if (model.caption) {
info.push(`with caption ${model.caption}`);
}
return info.join(', ') + '\n';
};
const generateEmbedMarkdownPreview = (block: BlockDocumentInfo) => {
const isEmbedModel = (
model: DraftModel | null
): model is DraftModel<EmbedBlockModel> => {
return (
model?.flavour === 'affine:embed-linked-doc' ||
model?.flavour === 'affine:embed-synced-doc'
);
};
const draftModel = yblockToDraftModal(block.yblock); const draftModel = yblockToDraftModal(block.yblock);
markdown = if (!isEmbedModel(draftModel)) {
block.parentFlavour === 'affine:database' return null;
? `database · ${block.additional?.databaseName}\n` }
: ((draftModel ? await markdownAdapter.fromBlock(draftModel) : null)
?.file ?? null); const url = getDocLink(block.docId, draftModel.id);
return `[](${url})\n`;
};
const generateLatexMarkdownPreview = (block: BlockDocumentInfo) => {
let content =
typeof block.content === 'string'
? block.content.trim()
: block.content?.join('').trim();
content = content?.split('\n').join(' ') ?? '';
return `LaTeX, with value ${content}\n`;
};
const generateBookmarkMarkdownPreview = (block: BlockDocumentInfo) => {
const isBookmarkModel = (
model: DraftModel | null
): model is DraftModel<BookmarkBlockModel> => {
return bookmarkFlavours.has(model?.flavour ?? '');
};
const draftModel = yblockToDraftModal(block.yblock);
if (!isBookmarkModel(draftModel)) {
return null;
}
const title = draftModel.title;
const url = draftModel.url;
return `[${title}](${url})\n`;
};
const generateAttachmentMarkdownPreview = (block: BlockDocumentInfo) => {
const isAttachmentModel = (
model: DraftModel | null
): model is DraftModel<AttachmentBlockModel> => {
return model?.flavour === 'affine:attachment';
};
const draftModel = yblockToDraftModal(block.yblock);
if (!isAttachmentModel(draftModel)) {
return null;
}
return `[${draftModel.name}](${draftModel.sourceId})\n`;
};
const generateMarkdownPreview = async (block: BlockDocumentInfo) => {
if (markdownPreviewCache.has(block)) {
return markdownPreviewCache.get(block);
}
const flavour = block.flavour;
let markdown: string | null = null;
if (
flavour === 'affine:paragraph' ||
flavour === 'affine:list' ||
flavour === 'affine:code'
) {
const draftModel = yblockToDraftModal(block.yblock);
markdown =
block.parentFlavour === 'affine:database'
? generateDatabaseMarkdownPreview(block)
: ((draftModel ? await markdownAdapter.fromBlock(draftModel) : null)
?.file ?? null);
if (markdown) {
if (flavour === 'affine:code') {
markdown = trimCodeBlock(markdown);
} else if (flavour === 'affine:paragraph') {
markdown = trimParagraph(markdown);
}
}
} else if (flavour === 'affine:database') {
markdown = generateDatabaseMarkdownPreview(block);
} else if (
flavour === 'affine:embed-linked-doc' ||
flavour === 'affine:embed-synced-doc'
) {
markdown = generateEmbedMarkdownPreview(block);
} else if (flavour === 'affine:attachment') {
markdown = generateAttachmentMarkdownPreview(block);
} else if (flavour === 'affine:image') {
markdown = generateImageMarkdownPreview(block);
} else if (flavour === 'affine:surface' || flavour === 'affine:page') {
// skip
} else if (flavour === 'affine:latex') {
markdown = generateLatexMarkdownPreview(block);
} else if (bookmarkFlavours.has(flavour)) {
markdown = generateBookmarkMarkdownPreview(block);
} else {
console.warn(`unknown flavour: ${flavour}`);
}
if (markdown && flavour === 'affine:list') {
const blockDepth = getListDepth(block);
markdown = indentMarkdown(markdown, Math.max(0, blockDepth));
}
markdownPreviewCache.set(block, markdown);
return markdown;
};
return generateMarkdownPreview;
}
// remove the indent of the first line of list
// e.g.,
// ```
// - list item 1
// - list item 2
// ```
// becomes
// ```
// - list item 1
// - list item 2
// ```
function unindentMarkdown(markdown: string) {
const lines = markdown.split('\n');
const res: string[] = [];
let firstListFound = false;
let baseIndent = 0;
for (let current of lines) {
const indent = current.match(/^\s*/)?.[0]?.length ?? 0;
if (indent > 0) {
if (!firstListFound) {
// For the first list item, remove all indentation
firstListFound = true;
baseIndent = indent;
current = current.trimStart();
} else {
// For subsequent list items, maintain relative indentation
current = ' '.repeat(indent - baseIndent) + current.trimStart();
}
}
res.push(current);
} }
if (
flavour === 'affine:embed-linked-doc' || return res.join('\n');
flavour === 'affine:embed-synced-doc' }
) {
markdown = '🔗\n';
}
if (flavour === 'affine:attachment') {
markdown = '📃\n';
}
if (flavour === 'affine:image') {
markdown = '🖼️\n';
}
markdownPreviewCache.set(block, markdown);
return markdown;
};
async function crawlingDocData({ async function crawlingDocData({
docBuffer, docBuffer,
storageDocId, storageDocId,
rootDocBuffer, rootDocBuffer,
rootDocId,
}: WorkerInput & { type: 'doc' }): Promise<WorkerOutput> { }: WorkerInput & { type: 'doc' }): Promise<WorkerOutput> {
if (isEmptyUpdate(rootDocBuffer)) { if (isEmptyUpdate(rootDocBuffer)) {
console.warn('[worker]: Empty root doc buffer'); console.warn('[worker]: Empty root doc buffer');
@@ -210,12 +493,49 @@ async function crawlingDocData({
let summary = ''; let summary = '';
const blockDocuments: BlockDocumentInfo[] = []; const blockDocuments: BlockDocumentInfo[] = [];
const generateMarkdownPreview = generateMarkdownPreviewBuilder(
yRootDoc,
rootDocId,
blockDocuments
);
const blocks = ydoc.getMap<any>('blocks'); const blocks = ydoc.getMap<any>('blocks');
// build a parent map for quick lookup
// for each block, record its parent id
const parentMap: Record<string, string | null> = {};
for (const [id, block] of blocks.entries()) {
const children = block.get('sys:children') as YArray<string> | undefined;
if (children instanceof YArray && children.length) {
for (const child of children) {
parentMap[child] = id;
}
}
}
if (blocks.size === 0) { if (blocks.size === 0) {
return { deletedDoc: [docId] }; return { deletedDoc: [docId] };
} }
// find the nearest block that satisfies the predicate
const nearest = (
blockId: string,
predicate: (block: YMap<any>) => boolean
) => {
let current: string | null = blockId;
while (current) {
const block = blocks.get(current);
if (block && predicate(block)) {
return block;
}
current = parentMap[current] ?? null;
}
return null;
};
const nearestByFlavour = (blockId: string, flavour: string) =>
nearest(blockId, block => block.get('sys:flavour') === flavour);
let rootBlockId: string | null = null; let rootBlockId: string | null = null;
for (const block of blocks.values()) { for (const block of blocks.values()) {
const flavour = block.get('sys:flavour')?.toString(); const flavour = block.get('sys:flavour')?.toString();
@@ -261,21 +581,40 @@ async function crawlingDocData({
const flavour = block.get('sys:flavour')?.toString(); const flavour = block.get('sys:flavour')?.toString();
const parentFlavour = parentBlock?.get('sys:flavour')?.toString(); const parentFlavour = parentBlock?.get('sys:flavour')?.toString();
const noteBlock = nearestByFlavour(blockId, 'affine:note');
// display mode:
// - both: page and edgeless -> fallback to page
// - page: only page -> page
// - edgeless: only edgeless -> edgeless
// - undefined: edgeless (assuming it is a normal element on the edgeless)
let displayMode = noteBlock?.get('prop:displayMode') ?? 'edgeless';
if (displayMode === 'both') {
displayMode = 'page';
}
const noteBlockId: string | undefined = noteBlock
?.get('sys:id')
?.toString();
pushChildren(blockId, block); pushChildren(blockId, block);
const commonBlockProps = {
docId,
flavour,
blockId,
yblock: block,
additional: { displayMode, noteBlockId },
};
if (flavour === 'affine:page') { if (flavour === 'affine:page') {
docTitle = block.get('prop:title').toString(); docTitle = block.get('prop:title').toString();
blockDocuments.push({ blockDocuments.push({
docId, ...commonBlockProps,
flavour,
blockId,
content: docTitle, content: docTitle,
yblock: block,
}); });
} } else if (
if (
flavour === 'affine:paragraph' || flavour === 'affine:paragraph' ||
flavour === 'affine:list' || flavour === 'affine:list' ||
flavour === 'affine:code' flavour === 'affine:code'
@@ -313,9 +652,7 @@ async function crawlingDocData({
: undefined; : undefined;
blockDocuments.push({ blockDocuments.push({
docId, ...commonBlockProps,
flavour,
blockId,
content: text.toString(), content: text.toString(),
...refs.reduce<{ refDocId: string[]; ref: string[] }>( ...refs.reduce<{ refDocId: string[]; ref: string[] }>(
(prev, curr) => { (prev, curr) => {
@@ -327,17 +664,14 @@ async function crawlingDocData({
), ),
parentFlavour, parentFlavour,
parentBlockId, parentBlockId,
additional: { databaseName }, additional: { ...commonBlockProps.additional, databaseName },
yblock: block,
}); });
if (summaryLenNeeded > 0) { if (summaryLenNeeded > 0) {
summary += text.toString(); summary += text.toString();
summaryLenNeeded -= text.length; summaryLenNeeded -= text.length;
} }
} } else if (
if (
flavour === 'affine:embed-linked-doc' || flavour === 'affine:embed-linked-doc' ||
flavour === 'affine:embed-synced-doc' flavour === 'affine:embed-synced-doc'
) { ) {
@@ -346,34 +680,27 @@ async function crawlingDocData({
// reference info // reference info
const params = block.get('prop:params') ?? {}; const params = block.get('prop:params') ?? {};
blockDocuments.push({ blockDocuments.push({
docId, ...commonBlockProps,
flavour,
blockId,
refDocId: [pageId], refDocId: [pageId],
ref: [JSON.stringify({ docId: pageId, ...params })], ref: [JSON.stringify({ docId: pageId, ...params })],
parentFlavour, parentFlavour,
parentBlockId, parentBlockId,
yblock: block,
}); });
} }
} } else if (
flavour === 'affine:attachment' ||
if (flavour === 'affine:attachment' || flavour === 'affine:image') { flavour === 'affine:image'
) {
const blobId = block.get('prop:sourceId'); const blobId = block.get('prop:sourceId');
if (typeof blobId === 'string') { if (typeof blobId === 'string') {
blockDocuments.push({ blockDocuments.push({
docId, ...commonBlockProps,
flavour,
blockId,
blob: [blobId], blob: [blobId],
parentFlavour, parentFlavour,
parentBlockId, parentBlockId,
yblock: block,
}); });
} }
} } else if (flavour === 'affine:surface') {
if (flavour === 'affine:surface') {
const texts = []; const texts = [];
const elementsObj = block.get('prop:elements'); const elementsObj = block.get('prop:elements');
@@ -403,17 +730,12 @@ async function crawlingDocData({
} }
blockDocuments.push({ blockDocuments.push({
docId, ...commonBlockProps,
flavour,
blockId,
content: texts, content: texts,
parentFlavour, parentFlavour,
parentBlockId, parentBlockId,
yblock: block,
}); });
} } else if (flavour === 'affine:database') {
if (flavour === 'affine:database') {
const texts = []; const texts = [];
const columnsObj = block.get('prop:columns'); const columnsObj = block.get('prop:columns');
const databaseTitle = block.get('prop:title'); const databaseTitle = block.get('prop:title');
@@ -450,11 +772,21 @@ async function crawlingDocData({
} }
blockDocuments.push({ blockDocuments.push({
docId, ...commonBlockProps,
flavour,
blockId,
content: texts, content: texts,
yblock: block, additional: {
...commonBlockProps.additional,
databaseName: databaseTitle?.toString(),
},
});
} else if (flavour === 'affine:latex') {
blockDocuments.push({
...commonBlockProps,
content: block.get('prop:latex')?.toString() ?? '',
});
} else if (bookmarkFlavours.has(flavour)) {
blockDocuments.push({
...commonBlockProps,
}); });
} }
} }
@@ -464,15 +796,28 @@ async function crawlingDocData({
const TARGET_PREVIEW_CHARACTER = 500; const TARGET_PREVIEW_CHARACTER = 500;
const TARGET_PREVIOUS_BLOCK = 1; const TARGET_PREVIOUS_BLOCK = 1;
const TARGET_FOLLOW_BLOCK = 4; const TARGET_FOLLOW_BLOCK = 4;
for (let i = 0; i < blockDocuments.length; i++) { for (const block of blockDocuments) {
const block = blockDocuments[i]; if (block.ref?.length) {
if (block.ref) { const target = block;
// should only generate the markdown preview belong to the same affine:note
const noteBlock = nearestByFlavour(block.blockId, 'affine:note');
const sameNoteBlocks = noteBlock
? blockDocuments.filter(
candidate =>
nearestByFlavour(candidate.blockId, 'affine:note') === noteBlock
)
: [];
// only generate markdown preview for reference blocks // only generate markdown preview for reference blocks
let previewText = (await generateMarkdownPreview(block)) ?? ''; let previewText = (await generateMarkdownPreview(target)) ?? '';
let previousBlock = 0; let previousBlock = 0;
let followBlock = 0; let followBlock = 0;
let previousIndex = i; let previousIndex = sameNoteBlocks.findIndex(
let followIndex = i; block => block.blockId === target.blockId
);
let followIndex = previousIndex;
while ( while (
!( !(
@@ -480,14 +825,14 @@ async function crawlingDocData({
previewText.length > TARGET_PREVIEW_CHARACTER || // stop if preview text reaches the limit previewText.length > TARGET_PREVIEW_CHARACTER || // stop if preview text reaches the limit
((previousBlock >= TARGET_PREVIOUS_BLOCK || previousIndex < 0) && ((previousBlock >= TARGET_PREVIOUS_BLOCK || previousIndex < 0) &&
(followBlock >= TARGET_FOLLOW_BLOCK || (followBlock >= TARGET_FOLLOW_BLOCK ||
followIndex >= blockDocuments.length)) followIndex >= sameNoteBlocks.length))
) // stop if no more blocks, or preview block reaches the limit ) // stop if no more blocks, or preview block reaches the limit
) )
) { ) {
if (previousBlock < TARGET_PREVIOUS_BLOCK) { if (previousBlock < TARGET_PREVIOUS_BLOCK) {
previousIndex--; previousIndex--;
const block = const block =
previousIndex >= 0 ? blockDocuments.at(previousIndex) : null; previousIndex >= 0 ? sameNoteBlocks.at(previousIndex) : null;
const markdown = block const markdown = block
? await generateMarkdownPreview(block) ? await generateMarkdownPreview(block)
: null; : null;
@@ -497,14 +842,14 @@ async function crawlingDocData({
markdown markdown
) /* A small hack to skip blocks with the same content */ ) /* A small hack to skip blocks with the same content */
) { ) {
previewText = markdown + previewText; previewText = markdown + '\n' + previewText;
previousBlock++; previousBlock++;
} }
} }
if (followBlock < TARGET_FOLLOW_BLOCK) { if (followBlock < TARGET_FOLLOW_BLOCK) {
followIndex++; followIndex++;
const block = blockDocuments.at(followIndex); const block = sameNoteBlocks.at(followIndex);
const markdown = block const markdown = block
? await generateMarkdownPreview(block) ? await generateMarkdownPreview(block)
: null; : null;
@@ -514,13 +859,13 @@ async function crawlingDocData({
markdown markdown
) /* A small hack to skip blocks with the same content */ ) /* A small hack to skip blocks with the same content */
) { ) {
previewText = previewText + markdown; previewText = previewText + '\n' + markdown;
followBlock++; followBlock++;
} }
} }
} }
block.markdownPreview = previewText; block.markdownPreview = unindentMarkdown(previewText);
} }
} }
// #endregion // #endregion

View File

@@ -30,12 +30,14 @@ export type WorkerInput =
| { | {
type: 'rootDoc'; type: 'rootDoc';
rootDocBuffer: Uint8Array; rootDocBuffer: Uint8Array;
rootDocId: string;
allIndexedDocs: string[]; allIndexedDocs: string[];
reindexAll?: boolean; reindexAll?: boolean;
} }
| { | {
type: 'doc'; type: 'doc';
storageDocId: string; storageDocId: string;
rootDocId: string;
rootDocBuffer: Uint8Array; rootDocBuffer: Uint8Array;
docBuffer: Uint8Array; docBuffer: Uint8Array;
}; };

View File

@@ -1486,5 +1486,7 @@
"com.affine.editor.at-menu.import": "Import", "com.affine.editor.at-menu.import": "Import",
"com.affine.editor.at-menu.more-docs-hint": "{{count}} more docs", "com.affine.editor.at-menu.more-docs-hint": "{{count}} more docs",
"com.affine.editor.at-menu.journal": "Journal", "com.affine.editor.at-menu.journal": "Journal",
"com.affine.editor.at-menu.date-picker": "Select a specific date" "com.affine.editor.at-menu.date-picker": "Select a specific date",
"com.affine.editor.bi-directional-link-panel.show": "Show",
"com.affine.editor.bi-directional-link-panel.hide": "Hide"
} }

View File

@@ -7,6 +7,7 @@ import { coreUrl, openHomePage } from '@affine-test/kit/utils/load-page';
import { import {
clickNewPageButton, clickNewPageButton,
createLinkedPage, createLinkedPage,
getBlockSuiteEditorTitle,
waitForEmptyEditor, waitForEmptyEditor,
} from '@affine-test/kit/utils/page-logic'; } from '@affine-test/kit/utils/page-logic';
import { expect, type Locator } from '@playwright/test'; import { expect, type Locator } from '@playwright/test';
@@ -418,3 +419,31 @@ test('@ popover with click "select a specific date" should show a date picker',
page.locator('affine-reference:has-text("' + date + '")') page.locator('affine-reference:has-text("' + date + '")')
).toBeVisible(); ).toBeVisible();
}); });
test('linked doc should show markdown preview in the backlink section', async ({
page,
}) => {
await waitForEmptyEditor(page);
await page.keyboard.type('source page');
await page.keyboard.press('Enter');
await page.keyboard.type('some inline content');
await page.keyboard.press('Enter');
await createLinkedPage(page, 'Test Page');
await page.locator('affine-reference:has-text("Test Page")').click();
await expect(getBlockSuiteEditorTitle(page)).toHaveText('Test Page');
await page
.getByRole('button', {
name: 'Show',
})
.click();
await page.getByRole('button', { name: 'source page' }).click();
await expect(page.locator('text-renderer')).toContainText(
'some inline content'
);
await expect(page.locator('text-renderer')).toContainText('Test Page');
});