feat: linked doc supports aliases (#9009)

Closes: [AF-1882](https://linear.app/affine-design/issue/AF-1882/实现-generatedocurlservice)
Upstreams: https://github.com/toeverything/blocksuite/pull/8806
This commit is contained in:
fundon
2024-12-09 03:12:09 +00:00
parent efa4a80019
commit 0bafc35aad
8 changed files with 394 additions and 33 deletions
@@ -30,6 +30,7 @@ import * as styles from './styles.css';
interface AffinePageReferenceProps {
pageId: string;
params?: URLSearchParams;
title?: string | null; // title alias
className?: string;
Icon?: ComponentType;
onClick?: (e: MouseEvent) => void;
@@ -38,20 +39,21 @@ interface AffinePageReferenceProps {
function AffinePageReferenceInner({
pageId,
params,
title,
Icon: UserIcon,
}: AffinePageReferenceProps) {
const docDisplayMetaService = useService(DocDisplayMetaService);
const docsService = useService(DocsService);
const i18n = useI18n();
let linkWithMode: DocMode | null = null;
let linkToNode = false;
let referenceWithMode: DocMode | null = null;
let referenceToNode = false;
if (params) {
const m = params.get('mode');
if (m && (m === 'page' || m === 'edgeless')) {
linkWithMode = m as DocMode;
referenceWithMode = m as DocMode;
}
linkToNode = params.has('blockIds') || params.has('elementIds');
referenceToNode = params.has('blockIds') || params.has('elementIds');
}
const Icon = useLiveData(
@@ -61,25 +63,33 @@ function AffinePageReferenceInner({
}
return get(
docDisplayMetaService.icon$(pageId, {
mode: linkWithMode ?? undefined,
mode: referenceWithMode ?? undefined,
reference: true,
referenceToNode: linkToNode,
referenceToNode,
hasTitleAlias: Boolean(title),
})
);
})
);
const notFound = !useLiveData(docsService.list.doc$(pageId));
let title = useLiveData(
const docTitle = useLiveData(
docDisplayMetaService.title$(pageId, { reference: true })
);
title = notFound ? i18n.t('com.affine.notFoundPage.title') : title;
if (notFound) {
title = i18n.t('com.affine.notFoundPage.title');
}
if (!title) {
title = i18n.t(docTitle);
}
return (
<span className={notFound ? styles.notFound : ''}>
<Icon className={styles.pageReferenceIcon} />
<span className="affine-reference-title">{i18n.t(title)}</span>
<span className="affine-reference-title">{title}</span>
</span>
);
}
@@ -87,6 +97,7 @@ function AffinePageReferenceInner({
export function AffinePageReference({
pageId,
params,
title,
className,
Icon,
onClick: userOnClick,
@@ -152,7 +163,12 @@ export function AffinePageReference({
onClick={onClick}
className={clsx(styles.pageReferenceLink, className)}
>
<AffinePageReferenceInner pageId={pageId} params={params} Icon={Icon} />
<AffinePageReferenceInner
pageId={pageId}
params={params}
title={title}
Icon={Icon}
/>
</WorkbenchLink>
);
}
@@ -161,6 +177,7 @@ export function AffineSharedPageReference({
pageId,
docCollection,
params,
title,
Icon,
onClick: userOnClick,
}: AffinePageReferenceProps & {
@@ -213,7 +230,12 @@ export function AffineSharedPageReference({
onClick={onClick}
className={styles.pageReferenceLink}
>
<AffinePageReferenceInner pageId={pageId} params={params} Icon={Icon} />
<AffinePageReferenceInner
pageId={pageId}
params={params}
title={title}
Icon={Icon}
/>
</Link>
);
}
@@ -56,6 +56,7 @@ import {
patchEmbedLinkedDocBlockConfig,
patchForMobile,
patchForSharedPage,
patchGenerateDocUrlExtension,
patchNotificationService,
patchParseDocUrlExtension,
patchPeekViewService,
@@ -112,6 +113,8 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
const pageId = data.pageId;
if (!pageId) return <span />;
// title alias
const title = data.title;
const params = toURLSearchParams(data.params);
if (workspaceService.workspace.openOptions.isSharedMode) {
@@ -120,11 +123,14 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
docCollection={workspaceService.workspace.docCollection}
pageId={pageId}
params={params}
title={title}
/>
);
}
return <AffinePageReference pageId={pageId} params={params} />;
return (
<AffinePageReference pageId={pageId} params={params} title={title} />
);
};
}, [workspaceService]);
@@ -147,6 +153,7 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
patched = patched.concat(patchPeekViewService(peekViewService));
patched = patched.concat(patchEdgelessClipboard());
patched = patched.concat(patchParseDocUrlExtension(framework));
patched = patched.concat(patchGenerateDocUrlExtension(framework));
patched = patched.concat(patchQuickSearchService(framework));
patched = patched.concat(patchEmbedLinkedDocBlockConfig(framework));
if (shared) {
@@ -52,7 +52,7 @@ function createCopyLinkToBlockMenuItem(
const options: UseSharingUrl = {
workspaceId,
pageId,
shareMode: mode,
mode,
blockIds: [model.id],
};
@@ -7,6 +7,7 @@ import {
toReactNode,
type useConfirmModal,
} from '@affine/component';
import { WorkspaceServerService } from '@affine/core/modules/cloud';
import type { EditorService } from '@affine/core/modules/editor';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { resolveLinkToDoc } from '@affine/core/modules/navigation';
@@ -47,6 +48,7 @@ import {
EdgelessRootBlockComponent,
EmbedLinkedDocBlockComponent,
EmbedLinkedDocBlockConfigExtension,
GenerateDocUrlExtension,
MobileSpecsPatches,
NotificationExtension,
ParseDocUrlExtension,
@@ -55,6 +57,7 @@ import {
ReferenceNodeConfigExtension,
} from '@blocksuite/affine/blocks';
import { type BlockSnapshot, Text } from '@blocksuite/affine/store';
import type { ReferenceParams } from '@blocksuite/affine-model';
import {
AIChatBlockSchema,
type DocProps,
@@ -68,6 +71,7 @@ import { customElement } from 'lit/decorators.js';
import { literal } from 'lit/static-html.js';
import { pick } from 'lodash-es';
import { generateUrl } from '../../../../hooks/affine/use-share-url';
import { createKeyboardToolbarConfig } from './widgets/keyboard-toolbar';
export type ReferenceReactRenderer = (
@@ -110,13 +114,13 @@ export function patchReferenceRenderer(
reactToLit: (element: ElementOrFactory) => TemplateResult,
reactRenderer: ReferenceReactRenderer
): ExtensionType {
const litRenderer = (reference: AffineReference) => {
const customContent = (reference: AffineReference) => {
const node = reactRenderer(reference);
return reactToLit(node);
};
return ReferenceNodeConfigExtension({
customContent: litRenderer,
customContent,
});
}
@@ -463,18 +467,32 @@ export function patchParseDocUrlExtension(framework: FrameworkProvider) {
const info = resolveLinkToDoc(url);
if (!info || info.workspaceId !== workspaceService.workspace.id) return;
return {
docId: info.docId,
blockIds: info.blockIds,
elementIds: info.elementIds,
mode: info.mode,
};
delete info.refreshKey;
return info;
},
});
return [ParseDocUrl];
}
export function patchGenerateDocUrlExtension(framework: FrameworkProvider) {
const workspaceService = framework.get(WorkspaceService);
const workspaceServerService = framework.get(WorkspaceServerService);
const GenerateDocUrl = GenerateDocUrlExtension({
generateDocUrl(pageId: string, params?: ReferenceParams) {
return generateUrl({
...params,
pageId,
workspaceId: workspaceService.workspace.id,
baseUrl: workspaceServerService.server?.baseUrl ?? location.origin,
});
},
});
return [GenerateDocUrl];
}
export function patchEdgelessClipboard() {
class EdgelessClipboardWatcher extends BlockServiceWatcher {
static override readonly flavour = 'affine:page';
@@ -93,7 +93,7 @@ function createCopyLinkToBlockMenuItem(
const mode = editor.mode$.value;
const pageId = editor.doc.id;
const workspaceId = editor.doc.workspace.id;
const options: UseSharingUrl = { workspaceId, pageId, shareMode: mode };
const options: UseSharingUrl = { workspaceId, pageId, mode };
let type = '';
if (mode === 'page') {
@@ -13,7 +13,7 @@ import { useCallback } from 'react';
export type UseSharingUrl = {
workspaceId: string;
pageId: string;
shareMode?: DocMode;
mode?: DocMode;
blockIds?: string[];
elementIds?: string[];
xywh?: string; // not needed currently
@@ -30,7 +30,7 @@ export const generateUrl = ({
pageId,
blockIds,
elementIds,
shareMode: mode,
mode,
xywh, // not needed currently
}: UseSharingUrl & { baseUrl: string }) => {
try {
@@ -38,23 +38,24 @@ export const generateUrl = ({
const search = toURLSearchParams({ mode, blockIds, elementIds, xywh });
if (search?.size) url.search = search.toString();
return url.toString();
} catch {
return null;
} catch (err) {
console.error(err);
return undefined;
}
};
const getShareLinkType = ({
shareMode,
mode,
blockIds,
elementIds,
}: {
shareMode?: DocMode;
mode?: DocMode;
blockIds?: string[];
elementIds?: string[];
}) => {
if (shareMode === 'page') {
if (mode === 'page') {
return 'doc';
} else if (shareMode === 'edgeless') {
} else if (mode === 'edgeless') {
return 'whiteboard';
} else if (blockIds && blockIds.length > 0) {
return 'block';
@@ -131,17 +132,17 @@ export const useSharingUrl = ({ workspaceId, pageId }: UseSharingUrl) => {
const serverService = useService(ServerService);
const onClickCopyLink = useCallback(
(shareMode?: DocMode, blockIds?: string[], elementIds?: string[]) => {
(mode?: DocMode, blockIds?: string[], elementIds?: string[]) => {
const sharingUrl = generateUrl({
baseUrl: serverService.server.baseUrl,
workspaceId,
pageId,
blockIds,
elementIds,
shareMode, // if view is not provided, use the current view
mode, // if view is not provided, use the current view
});
const type = getShareLinkType({
shareMode,
mode,
blockIds,
elementIds,
});
@@ -1,6 +1,7 @@
import { extractEmojiIcon } from '@affine/core/utils';
import { i18nTime } from '@affine/i18n';
import {
AliasIcon as LitAliasIcon,
BlockLinkIcon as LitBlockLinkIcon,
EdgelessIcon as LitEdgelessIcon,
LinkedEdgelessIcon as LitLinkedEdgelessIcon,
@@ -11,6 +12,7 @@ import {
YesterdayIcon as LitYesterdayIcon,
} from '@blocksuite/icons/lit';
import {
AliasIcon,
BlockLinkIcon,
EdgelessIcon,
LinkedEdgelessIcon,
@@ -42,6 +44,7 @@ interface DocDisplayIconOptions<T extends IconType> {
mode?: 'edgeless' | 'page';
reference?: boolean;
referenceToNode?: boolean;
hasTitleAlias?: boolean;
/**
* @default true
*/
@@ -57,6 +60,7 @@ interface DocDisplayTitleOptions {
}
const rcIcons = {
AliasIcon,
BlockLinkIcon,
EdgelessIcon,
LinkedEdgelessIcon,
@@ -67,6 +71,7 @@ const rcIcons = {
YesterdayIcon,
};
const litIcons = {
AliasIcon: LitAliasIcon,
BlockLinkIcon: LitBlockLinkIcon,
EdgelessIcon: LitEdgelessIcon,
LinkedEdgelessIcon: LitLinkedEdgelessIcon,
@@ -130,6 +135,12 @@ export class DocDisplayMetaService extends Service {
const mode = doc ? get(doc.primaryMode$) : undefined;
const finalMode = options?.mode ?? mode ?? 'page';
const referenceToNode = !!(options?.reference && options.referenceToNode);
const hasTitleAlias = !!(options?.reference && options?.hasTitleAlias);
// increases block link priority with title alias
if (hasTitleAlias) {
return iconSet.AliasIcon;
}
// increases block link priority
if (referenceToNode) {
+302
View File
@@ -580,3 +580,305 @@ test('should show edgeless content when switching card view of linked mode doc i
edgelessText.y + edgelessText.height
);
});
// Aliases & Copy link
test.describe('Customize linked doc title and description', () => {
// Inline View
test('should set a custom title for inline link', async ({ page }) => {
await page.keyboard.press('Enter');
await createLinkedPage(page, 'Test Page');
const link = page.locator('affine-reference');
const title = link.locator('.affine-reference-title');
await link.hover();
const toolbar = page.locator('reference-popup');
// Copies link
await toolbar.getByRole('button', { name: 'Copy link' }).click();
const url0 = await link.locator('a').getAttribute('href');
const url1 = await (
await page.evaluateHandle(() => navigator.clipboard.readText())
).jsonValue();
expect(url0).not.toBeNull();
expect(new URL(url0!, coreUrl).pathname).toBe(new URL(url1).pathname);
// Edits title
await toolbar.getByRole('button', { name: 'Edit' }).click();
// Title alias
await page.keyboard.type('Test Page Alias');
await page.keyboard.press('Enter');
await expect(title).toHaveText('Test Page Alias');
// Original title
await link.hover();
const docTitle = toolbar.getByRole('button', { name: 'Doc title' });
await expect(docTitle).toHaveText('Test Page', { useInnerText: true });
// Edits title
await toolbar.getByRole('button', { name: 'Edit' }).click();
const aliasPopup = page.locator('reference-alias-popup');
// Input
await expect(aliasPopup.locator('input')).toHaveValue('Test Page Alias');
// Reset
await aliasPopup.getByRole('button', { name: 'Reset' }).click();
await expect(title).toHaveText('Test Page');
await link.hover();
await expect(docTitle).toBeHidden();
});
// Card View
test('should set a custom title and description for card link', async ({
page,
}) => {
await page.keyboard.press('Enter');
await createLinkedPage(page, 'Test Page');
const inlineLink = page.locator('affine-reference');
const inlineToolbar = page.locator('reference-popup');
await inlineLink.hover();
// Copies link
await inlineToolbar.getByRole('button', { name: 'Copy link' }).click();
const url0 = await (
await page.evaluateHandle(() => navigator.clipboard.readText())
).jsonValue();
// Edits title
await inlineToolbar.getByRole('button', { name: 'Edit' }).click();
// Title alias
await page.keyboard.type('Test Page Alias');
await page.keyboard.press('Enter');
await inlineLink.hover();
// Switches to card view
await inlineToolbar.getByRole('button', { name: 'Switch view' }).click();
await inlineToolbar.getByRole('button', { name: 'Card view' }).click();
const cardLink = page.locator('affine-embed-linked-doc-block');
const cardTitle = cardLink.locator(
'.affine-embed-linked-doc-content-title-text'
);
const cardDescription = cardLink.locator(
'.affine-embed-linked-doc-content-note.alias'
);
const cardToolbar = page.locator('affine-embed-card-toolbar');
await cardLink.click();
// Copies link
await cardToolbar.getByRole('button', { name: 'Copy link' }).click();
const url1 = await (
await page.evaluateHandle(() => navigator.clipboard.readText())
).jsonValue();
expect(url0).not.toBeNull();
expect(url1).not.toBeNull();
expect(url0).toBe(url1);
const docTitle = cardToolbar.getByRole('button', { name: 'Doc title' });
await expect(docTitle).toHaveText('Test Page', { useInnerText: true });
await expect(cardTitle).toHaveText('Test Page Alias');
// Edits title & description
await cardToolbar.getByRole('button', { name: 'Edit' }).click();
const cardEditPopup = page.locator('embed-card-edit-modal');
// Title alias
await page.keyboard.type('Test Page Alias Again');
await page.keyboard.press('Tab');
// Description alias
await page.keyboard.type('This is a new description');
// Saves aliases
await cardEditPopup.getByRole('button', { name: 'Save' }).click();
await expect(cardTitle).toHaveText('Test Page Alias Again');
await expect(cardDescription).toHaveText('This is a new description');
await cardLink.click();
// Switches to inline view
{
await cardToolbar.getByRole('button', { name: 'Switch view' }).click();
await cardToolbar.getByRole('button', { name: 'Inline view' }).click();
// Focuses inline editor
const bounds = (await inlineLink.boundingBox())!;
await page.mouse.click(
bounds.x + bounds.width + 30,
bounds.y + bounds.height / 2
);
await inlineLink.hover();
const title = inlineLink.locator('.affine-reference-title');
await expect(title).toHaveText('Test Page Alias Again');
// Switches to card view
await inlineToolbar.getByRole('button', { name: 'Switch view' }).click();
await inlineToolbar.getByRole('button', { name: 'Card view' }).click();
}
await cardLink.click();
await cardToolbar.getByRole('button', { name: 'Edit' }).click();
// Resets
await cardEditPopup.getByRole('button', { name: 'Reset' }).click();
await expect(cardTitle).toHaveText('Test Page');
await expect(cardDescription).toBeHidden();
});
// Embed View
test('should automatically switch to card view and set a custom title and description', async ({
page,
}) => {
await page.keyboard.press('Enter');
await createLinkedPage(page, 'Test Page');
const inlineLink = page.locator('affine-reference');
const inlineToolbar = page.locator('reference-popup');
await inlineLink.hover();
// Copies link
await inlineToolbar.getByRole('button', { name: 'Copy link' }).click();
const url0 = await (
await page.evaluateHandle(() => navigator.clipboard.readText())
).jsonValue();
// Switches to card view
await inlineToolbar.getByRole('button', { name: 'Switch view' }).click();
await inlineToolbar.getByRole('button', { name: 'Card view' }).click();
const cardLink = page.locator('affine-embed-linked-doc-block');
const cardTitle = cardLink.locator(
'.affine-embed-linked-doc-content-title-text'
);
const cardDescription = cardLink.locator(
'.affine-embed-linked-doc-content-note.alias'
);
const cardToolbar = page.locator('affine-embed-card-toolbar');
await cardLink.click();
// Copies link
await cardToolbar.getByRole('button', { name: 'Copy link' }).click();
const url1 = await (
await page.evaluateHandle(() => navigator.clipboard.readText())
).jsonValue();
// Switches to embed view
await cardToolbar.getByRole('button', { name: 'Switch view' }).click();
await cardToolbar.getByRole('button', { name: 'Embed view' }).click();
const embedLink = page.locator('affine-embed-synced-doc-block');
const embedTitle = embedLink.locator('.affine-embed-synced-doc-title');
const embedToolbar = page.locator('affine-embed-card-toolbar');
await embedLink.click();
// Copies link
await embedToolbar.getByRole('button', { name: 'Copy link' }).click();
const url2 = await (
await page.evaluateHandle(() => navigator.clipboard.readText())
).jsonValue();
expect(url0).not.toBeNull();
expect(url1).not.toBeNull();
expect(url2).not.toBeNull();
expect(url0).toBe(url1);
expect(url1).toBe(url2);
// Edits title & description
await embedToolbar.getByRole('button', { name: 'Edit' }).click();
const embedEditPopup = page.locator('embed-card-edit-modal');
// Title alias
await page.keyboard.type('Test Page Alias Again');
await page.keyboard.press('Tab');
// Description alias
await page.keyboard.type('This is a new description');
// Cancels
await embedEditPopup.getByRole('button', { name: 'Cancel' }).click();
await expect(embedEditPopup).toBeHidden();
await embedLink.click();
// Edits title & description
await embedToolbar.getByRole('button', { name: 'Edit' }).click();
// Title alias
await page.keyboard.type('Test Page Alias');
await page.keyboard.press('Tab');
// Description alias
await page.keyboard.type('This is a new description');
// Saves aliases
await embedEditPopup.getByRole('button', { name: 'Save' }).click();
// Automatically switch to card view
await expect(embedLink).toBeHidden();
await expect(cardTitle).toHaveText('Test Page Alias');
await expect(cardDescription).toHaveText('This is a new description');
await cardLink.click();
const docTitle = cardToolbar.getByRole('button', { name: 'Doc title' });
await expect(docTitle).toHaveText('Test Page', { useInnerText: true });
await expect(cardTitle).toHaveText('Test Page Alias');
// Switches to embed view
await cardToolbar.getByRole('button', { name: 'Switch view' }).click();
await cardToolbar.getByRole('button', { name: 'Embed view' }).click();
await expect(embedTitle).toHaveText('Test Page');
await embedLink.click();
// Switches to inline view
{
await embedToolbar.getByRole('button', { name: 'Switch view' }).click();
await embedToolbar.getByRole('button', { name: 'Inline view' }).click();
// Focuses inline editor
const bounds = (await inlineLink.boundingBox())!;
await page.mouse.click(
bounds.x + bounds.width + 30,
bounds.y + bounds.height / 2
);
await inlineLink.hover();
// Upstreams: https://github.com/toeverything/blocksuite/pull/8884
// const title = inlineLink.locator('.affine-reference-title');
// await expect(title).toHaveText('Test Page');
// Switches to embed view
await inlineToolbar.getByRole('button', { name: 'Switch view' }).click();
await inlineToolbar.getByRole('button', { name: 'Embed view' }).click();
}
await embedLink.click();
await expect(embedTitle).toHaveText('Test Page');
await expect(
embedToolbar.getByRole('button', { name: 'Doc title' })
).toBeHidden();
});
});