mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 09:59:55 +08:00
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:
@@ -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',
|
||||
});
|
||||
|
||||
+39
-3
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+235
-18
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user