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
@@ -7,7 +7,12 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { DocMode } from '@blocksuite/affine/blocks';
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 { nanoid } from 'nanoid';
import {
@@ -36,6 +41,7 @@ function AffinePageReferenceInner({
Icon: UserIcon,
}: AffinePageReferenceProps) {
const docDisplayMetaService = useService(DocDisplayMetaService);
const docsService = useService(DocsService);
const i18n = useI18n();
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 })
);
title = notFound ? i18n.t('com.affine.notFoundPage.title') : title;
return (
<>
<span className={notFound ? styles.notFound : ''}>
<Icon className={styles.pageReferenceIcon} />
<span className="affine-reference-title">{i18n.t(title)}</span>
</>
</span>
);
}
@@ -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({
verticalAlign: 'middle',
fontSize: '1.1em',
transform: 'translate(2px, -1px)',
color: cssVarV2('icon/primary'),
});
export const pageReferenceLink = style({
@@ -12,3 +14,13 @@ export const pageReferenceLink = style({
wordBreak: 'break-word',
hyphens: 'auto',
});
export const notFound = style({
color: cssVarV2('text/secondary'),
textDecoration: 'line-through',
});
globalStyle('affine-reference .affine-reference', {
color: 'inherit !important',
textDecoration: 'none !important',
});
@@ -1,5 +1,6 @@
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({
width: '100%',
@@ -49,7 +50,6 @@ export const title = style({
});
export const showButton = style({
width: '56px',
height: '28px',
borderRadius: '8px',
border: '1px solid ' + cssVar('--affine-border-color'),
@@ -74,9 +74,45 @@ export const linksTitles = style({
export const link = style({
width: '100%',
height: '32px',
height: '30px',
display: 'flex',
alignItems: 'center',
gap: '4px',
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'),
},
},
});
@@ -1,32 +1,179 @@
import {
Button,
createReactComponentFromLit,
useLitPortalFactory,
} from '@affine/component';
import { TextRenderer } from '@affine/core/blocksuite/presets';
import {
type Backlink,
DocLinksService,
type 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 { LiveData, useLiveData, useServices } from '@toeverything/infra';
import { Fragment, useCallback, useState } from 'react';
import type { JobMiddleware } from '@blocksuite/affine/store';
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 {
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 = () => {
const [show, setShow] = useState(false);
const { docLinksService } = useServices({
const { docLinksService, workspaceService } = useServices({
DocLinksService,
WorkspaceService,
});
const [extensions, portals] = usePreviewExtensions();
const t = useI18n();
const links = useLiveData(
show ? docLinksService.links.links$ : new LiveData([] as Link[])
);
const backlinks = useLiveData(
show ? docLinksService.backlinks.backlinks$ : new LiveData([] as Backlink[])
const backlinkGroups = useLiveData(
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(() => {
setShow(!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 (
<div className={styles.container}>
{!show && (
@@ -37,9 +184,11 @@ export const BiDirectionalLinkPanel = () => {
<div className={styles.titleLine}>
<div className={styles.title}>Bi-Directional Links</div>
<div className={styles.showButton} onClick={handleClickShow}>
{show ? 'Hide' : 'Show'}
</div>
<Button className={styles.showButton} onClick={handleClickShow}>
{show
? t['com.affine.editor.bi-directional-link-panel.hide']()
: t['com.affine.editor.bi-directional-link-panel.show']()}
</Button>
</div>
{show && (
@@ -49,17 +198,78 @@ export const BiDirectionalLinkPanel = () => {
</div>
<div className={styles.linksContainer}>
<div className={styles.linksTitles}>
{t['com.affine.page-properties.backlinks']()} · {backlinks.length}
{t['com.affine.page-properties.backlinks']()} · {backlinkCount}
</div>
{backlinks.map(link => (
<Fragment key={link.docId}>
<div className={styles.link}>
<AffinePageReference key={link.docId} pageId={link.docId} />
{backlinkGroups.map(linkGroup => (
<CollapsibleSection
key={linkGroup.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>
<br />
<pre style={{ opacity: 0.5 }}>{link.markdownPreview}</pre>
<br />
</Fragment>
</CollapsibleSection>
))}
</div>
<div className={styles.linksContainer}>
@@ -78,6 +288,13 @@ export const BiDirectionalLinkPanel = () => {
</div>
</>
)}
{
<>
{portals.map(p => (
<Fragment key={p.id}>{p.portal}</Fragment>
))}
</>
}
</div>
);
};