fix(core): improve doc title and icon display (#9755)

Closes: [AF-2132](https://linear.app/affine-design/issue/AF-2132/优化-emoji-title-和-icon-显示)
This commit is contained in:
fundon
2025-01-17 11:30:10 +00:00
committed by Fangdun Tsai
parent d048ac6c91
commit 7436c139ab
13 changed files with 159 additions and 74 deletions

View File

@@ -229,10 +229,9 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
title$ = computed(() => { title$ = computed(() => {
const { pageId, params, title } = this.referenceInfo$.value; const { pageId, params, title } = this.referenceInfo$.value;
return ( return (
title ||
this.std this.std
.get(DocDisplayMetaProvider) .get(DocDisplayMetaProvider)
.title(pageId, { params, title, referenced: true }) .title(pageId, { params, title, referenced: true }) || title
); );
}); });

View File

@@ -145,10 +145,9 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
get _title() { get _title() {
const { pageId, params, title } = this.referenceInfo; const { pageId, params, title } = this.referenceInfo;
return ( return (
title ||
this.block?.std this.block?.std
?.get(DocDisplayMetaProvider) ?.get(DocDisplayMetaProvider)
.title(pageId, { params, title, referenced: true }).value .title(pageId, { params, title, referenced: true }).value || title
); );
} }

View File

@@ -60,7 +60,7 @@ function AffinePageReferenceInner({
mode: referenceWithMode ?? undefined, mode: referenceWithMode ?? undefined,
reference: true, reference: true,
referenceToNode, referenceToNode,
hasTitleAlias: Boolean(title), title,
}) })
); );
}) })

View File

@@ -186,8 +186,8 @@ export function buildDocDisplayMetaExtension(framework: FrameworkProvider) {
const icon$ = docDisplayMetaService const icon$ = docDisplayMetaService
.icon$(docId, { .icon$(docId, {
type: 'lit', type: 'lit',
title,
reference: referenced, reference: referenced,
hasTitleAlias: Boolean(title),
referenceToNode: referenceToNode({ pageId: docId, params }), referenceToNode: referenceToNode({ pageId: docId, params }),
}) })
.map(iconBuilder); .map(iconBuilder);
@@ -204,7 +204,7 @@ export function buildDocDisplayMetaExtension(framework: FrameworkProvider) {
title( title(
docId: string, docId: string,
{ title = '', referenced }: DocDisplayMetaParams = {} { title, referenced }: DocDisplayMetaParams = {}
): Signal<string> { ): Signal<string> {
const title$ = docDisplayMetaService.title$(docId, { const title$ = docDisplayMetaService.title$(docId, {
title, title,
@@ -212,7 +212,7 @@ export function buildDocDisplayMetaExtension(framework: FrameworkProvider) {
}); });
const { signal: titleSignal, cleanup } = const { signal: titleSignal, cleanup } =
createSignalFromObservable<string>(title$, title); createSignalFromObservable<string>(title$, title ?? '');
this.disposables.push(cleanup); this.disposables.push(cleanup);

View File

@@ -59,8 +59,7 @@ const PageItem = ({
const Icon = useLiveData( const Icon = useLiveData(
docDisplayMetaService.icon$(docId, { compareDate: new Date() }) docDisplayMetaService.icon$(docId, { compareDate: new Date() })
); );
const titleMeta = useLiveData(docDisplayMetaService.title$(docId)); const title = useLiveData(docDisplayMetaService.title$(docId));
const title = i18n.t(titleMeta);
return ( return (
<WorkbenchLink <WorkbenchLink

View File

@@ -55,8 +55,7 @@ const ConflictItem = ({ docRecord }: { docRecord: DocRecord }) => {
const docId = docRecord.id; const docId = docRecord.id;
const i18n = useI18n(); const i18n = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService); const docDisplayMetaService = useService(DocDisplayMetaService);
const titleMeta = useLiveData(docDisplayMetaService.title$(docId)); const title = useLiveData(docDisplayMetaService.title$(docId));
const title = i18n.t(titleMeta);
return ( return (
<WorkbenchLink className={styles.docItem} to={`/${docId}`}> <WorkbenchLink className={styles.docItem} to={`/${docId}`}>

View File

@@ -84,8 +84,7 @@ const DocItem = ({ docRecord }: { docRecord: DocRecord }) => {
const Icon = useLiveData( const Icon = useLiveData(
docDisplayMetaService.icon$(docId, { compareDate: new Date() }) docDisplayMetaService.icon$(docId, { compareDate: new Date() })
); );
const titleMeta = useLiveData(docDisplayMetaService.title$(docId)); const title = useLiveData(docDisplayMetaService.title$(docId));
const title = i18n.t(titleMeta);
return ( return (
<WorkbenchLink aria-label={title} to={`/${docId}`}> <WorkbenchLink aria-label={title} to={`/${docId}`}>
<MobileMenuItem <MobileMenuItem

View File

@@ -23,13 +23,11 @@ interface JournalTodayActivityMenuItemProps {
type Category = 'created' | 'updated'; type Category = 'created' | 'updated';
const DocItem = ({ docId }: { docId: string }) => { const DocItem = ({ docId }: { docId: string }) => {
const i18n = useI18n();
const docDisplayMetaService = useService(DocDisplayMetaService); const docDisplayMetaService = useService(DocDisplayMetaService);
const Icon = useLiveData( const Icon = useLiveData(
docDisplayMetaService.icon$(docId, { compareDate: new Date() }) docDisplayMetaService.icon$(docId, { compareDate: new Date() })
); );
const titleMeta = useLiveData(docDisplayMetaService.title$(docId)); const title = useLiveData(docDisplayMetaService.title$(docId));
const title = i18n.t(titleMeta);
return ( return (
<WorkbenchLink aria-label={title} to={`/${docId}`}> <WorkbenchLink aria-label={title} to={`/${docId}`}>
<MenuItem prefixIcon={<Icon />}>{title}</MenuItem> <MenuItem prefixIcon={<Icon />}>{title}</MenuItem>

View File

@@ -91,14 +91,10 @@ export class AtMenuConfigService extends Service {
return null; return null;
} }
let title = docDisplayMetaService.title$(meta.id, { const title = docDisplayMetaService.title$(meta.id, {
reference: true, reference: true,
}).value; }).value;
if (typeof title === 'object' && 'i18nKey' in title) {
title = I18n.t(title);
}
if (!fuzzyMatch(title, query)) { if (!fuzzyMatch(title, query)) {
return null; return null;
} }

View File

@@ -40,16 +40,15 @@ interface DocDisplayIconOptions<T extends IconType> {
* by default, it will use the `primaryMode$` of the doc. * by default, it will use the `primaryMode$` of the doc.
*/ */
mode?: 'edgeless' | 'page'; mode?: 'edgeless' | 'page';
title?: string; // title alias
reference?: boolean; reference?: boolean;
referenceToNode?: boolean; referenceToNode?: boolean;
hasTitleAlias?: boolean;
/** /**
* @default true * @default true
*/ */
enableEmojiIcon?: boolean; enableEmojiIcon?: boolean;
} }
interface DocDisplayTitleOptions { interface DocDisplayTitleOptions {
originalTitle?: string;
title?: string; // title alias title?: string; // title alias
reference?: boolean; reference?: boolean;
/** /**
@@ -130,24 +129,31 @@ export class DocDisplayMetaService extends Service {
const iconSet = icons[options?.type ?? 'rc']; const iconSet = icons[options?.type ?? 'rc'];
return LiveData.computed(get => { return LiveData.computed(get => {
const enableEmojiIcon =
get(this.featureFlagService.flags.enable_emoji_doc_icon.$) &&
options?.enableEmojiIcon !== false;
const doc = get(this.docsService.list.doc$(docId)); const doc = get(this.docsService.list.doc$(docId));
const title = doc ? get(doc.title$) : ''; const referenced = !!options?.reference;
const titleAlias = referenced ? options?.title : undefined;
const originalTitle = doc ? get(doc.title$) : '';
const title = titleAlias ?? originalTitle;
const mode = doc ? get(doc.primaryMode$) : undefined; const mode = doc ? get(doc.primaryMode$) : undefined;
const finalMode = options?.mode ?? mode ?? 'page'; const finalMode = options?.mode ?? mode ?? 'page';
const referenceToNode = !!(options?.reference && options.referenceToNode); const referenceToNode = !!(referenced && options.referenceToNode);
const hasTitleAlias = !!(options?.reference && options?.hasTitleAlias);
// increases block link priority with title alias // emoji title
if (hasTitleAlias) { if (enableEmojiIcon && title) {
return iconSet.AliasIcon; const { emoji } = extractEmojiIcon(title);
if (emoji) return () => emoji;
} }
// increases block link priority // title alias
if (referenceToNode) { if (titleAlias) return iconSet.AliasIcon;
return iconSet.BlockLinkIcon;
}
// journal icon // link to specified block
if (referenceToNode) return iconSet.BlockLinkIcon;
// link to journal doc
const journalDate = this._toDayjs( const journalDate = this._toDayjs(
get(this.journalService.journalDate$(docId)) get(this.journalService.journalDate$(docId))
); );
@@ -155,22 +161,13 @@ export class DocDisplayMetaService extends Service {
return this.getJournalIcon(journalDate, options); return this.getJournalIcon(journalDate, options);
} }
// reference icon // link to regular doc (reference)
if (options?.reference) { if (options?.reference) {
return finalMode === 'edgeless' return finalMode === 'edgeless'
? iconSet.LinkedEdgelessIcon ? iconSet.LinkedEdgelessIcon
: iconSet.LinkedPageIcon; : iconSet.LinkedPageIcon;
} }
// emoji icon
const enableEmojiIcon =
get(this.featureFlagService.flags.enable_emoji_doc_icon.$) &&
options?.enableEmojiIcon !== false;
if (enableEmojiIcon) {
const { emoji } = extractEmojiIcon(title);
if (emoji) return () => emoji;
}
// default icon // default icon
return finalMode === 'edgeless' ? iconSet.EdgelessIcon : iconSet.PageIcon; return finalMode === 'edgeless' ? iconSet.EdgelessIcon : iconSet.PageIcon;
}); });
@@ -183,14 +180,28 @@ export class DocDisplayMetaService extends Service {
options?.enableEmojiIcon !== false; options?.enableEmojiIcon !== false;
const lng = get(this.i18nService.i18n.currentLanguageKey$); const lng = get(this.i18nService.i18n.currentLanguageKey$);
const doc = get(this.docsService.list.doc$(docId)); const doc = get(this.docsService.list.doc$(docId));
const referenced = !!options?.reference;
const titleAlias = referenced ? options?.title : undefined;
const originalTitle = doc ? get(doc.title$) : '';
const title = titleAlias ?? originalTitle;
// title alias // emoji title
if (options?.title) { if (enableEmojiIcon && title) {
return enableEmojiIcon const { rest } = extractEmojiIcon(title);
? extractEmojiIcon(options.title).rest if (rest) return rest;
: options.title;
// When the title has only one emoji character,
// if it's a journal document, the date should be displayed.
const journalDateString = get(this.journalService.journalDate$(docId));
if (journalDateString) {
return i18nTime(journalDateString, { absolute: { accuracy: 'day' } });
}
} }
// title alias
if (titleAlias) return titleAlias;
// doc not found
if (!doc) { if (!doc) {
return this.i18nService.i18n.i18next.t( return this.i18nService.i18n.i18next.t(
'com.affine.notFoundPage.title', 'com.affine.notFoundPage.title',
@@ -205,31 +216,16 @@ export class DocDisplayMetaService extends Service {
} }
// original title // original title
if (options?.originalTitle) return options.originalTitle; if (originalTitle) return originalTitle;
const docTitle = get(doc.title$);
// empty title // empty title
if (!docTitle) {
return this.i18nService.i18n.i18next.t('Untitled', { lng }); return this.i18nService.i18n.i18next.t('Untitled', { lng });
}
// reference
if (options?.reference) return docTitle;
// emoji icon
if (enableEmojiIcon) {
return extractEmojiIcon(docTitle).rest;
}
// default
return docTitle;
}); });
} }
getDocDisplayMeta(docRecord: DocRecord, originalTitle?: string) { getDocDisplayMeta(docRecord: DocRecord) {
return { return {
title: this.title$(docRecord.id, { originalTitle }).value, title: this.title$(docRecord.id).value,
icon: this.icon$(docRecord.id).value, icon: this.icon$(docRecord.id).value,
updatedDate: docRecord.meta$.value.updatedDate, updatedDate: docRecord.meta$.value.updatedDate,
}; };

View File

@@ -66,10 +66,7 @@ export class DocsQuickSearchSession
) )
.map(([doc, docRecord]) => { .map(([doc, docRecord]) => {
const { title, icon, updatedDate } = const { title, icon, updatedDate } =
this.docDisplayMetaService.getDocDisplayMeta( this.docDisplayMetaService.getDocDisplayMeta(docRecord);
docRecord,
'title' in doc ? doc.title : undefined
);
return { return {
id: 'doc:' + docRecord.id, id: 'doc:' + docRecord.id,
source: 'docs', source: 'docs',

View File

@@ -8,10 +8,17 @@ import { coreUrl, openHomePage } from '@affine-test/kit/utils/load-page';
import { import {
clickNewPageButton, clickNewPageButton,
createLinkedPage, createLinkedPage,
createTodayPage,
getBlockSuiteEditorTitle, getBlockSuiteEditorTitle,
waitForEditorLoad,
waitForEmptyEditor, waitForEmptyEditor,
} from '@affine-test/kit/utils/page-logic'; } from '@affine-test/kit/utils/page-logic';
import { expect, type Locator } from '@playwright/test'; import {
confirmExperimentalPrompt,
openEditorSetting,
openExperimentalFeaturesPanel,
} from '@affine-test/kit/utils/setting';
import { expect, type Locator, type Page } from '@playwright/test';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await openHomePage(page); await openHomePage(page);
@@ -19,6 +26,26 @@ test.beforeEach(async ({ page }) => {
await waitForEmptyEditor(page); await waitForEmptyEditor(page);
}); });
async function enableEmojiDocIcon(page: Page) {
// Opens settings panel
await openEditorSetting(page);
await openExperimentalFeaturesPanel(page);
await confirmExperimentalPrompt(page);
const settingModal = page.locator('[data-testid=setting-modal-content]');
const item = settingModal.locator('div').getByText('Emoji Doc Icon');
await item.waitFor({ state: 'attached' });
await expect(item).toBeVisible();
const button = item.locator('label');
const isChecked = await button.locator('input').isChecked();
if (!isChecked) {
await button.click();
}
// Closes settings panel
await page.keyboard.press('Escape');
}
async function notClickable(locator: Locator) { async function notClickable(locator: Locator) {
await expect(locator).toHaveAttribute('disabled', ''); await expect(locator).toHaveAttribute('disabled', '');
} }
@@ -908,4 +935,65 @@ test.describe('Customize linked doc title and description', () => {
embedToolbar.getByRole('button', { name: 'Doc title' }) embedToolbar.getByRole('button', { name: 'Doc title' })
).toBeHidden(); ).toBeHidden();
}); });
test('should show emoji doc icon in normal document', async ({ page }) => {
await waitForEditorLoad(page);
await enableEmojiDocIcon(page);
await clickNewPageButton(page);
const title = getBlockSuiteEditorTitle(page);
await title.click();
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();
// Edits title
await inlineToolbar.getByRole('button', { name: 'Edit' }).click();
// Title alias
await page.keyboard.type('🦀hello');
await page.keyboard.press('Enter');
const a = inlineLink.locator('a');
await expect(a).toHaveText('🦀hello');
await expect(a.locator('svg')).toBeHidden();
await expect(a.locator('.affine-reference-title')).toHaveText('hello');
});
test('should show emoji doc icon in journal document', async ({ page }) => {
await waitForEditorLoad(page);
await enableEmojiDocIcon(page);
await clickNewPageButton(page);
const title = getBlockSuiteEditorTitle(page);
await title.click();
await page.keyboard.press('Enter');
await createTodayPage(page);
const inlineLink = page.locator('affine-reference');
const inlineToolbar = page.locator('reference-popup');
await inlineLink.hover();
// Edits title
await inlineToolbar.getByRole('button', { name: 'Edit' }).click();
// Title alias
await page.keyboard.type('🦀');
await page.keyboard.press('Enter');
const a = inlineLink.locator('a');
const year = String(new Date().getFullYear());
await expect(a).toContainText('🦀');
await expect(a.locator('svg')).toBeHidden();
await expect(a.locator('.affine-reference-title')).toContainText(year);
});
}); });

View File

@@ -76,6 +76,21 @@ export const createLinkedPage = async (page: Page, pageName?: string) => {
.click(); .click();
}; };
export const createTodayPage = async (page: Page) => {
// fixme: workaround for @ popover not showing up when editor is not ready
await page.waitForTimeout(500);
await page.keyboard.type('@', { delay: 50 });
const linkedPagePopover = page.locator('.linked-doc-popover');
await expect(linkedPagePopover).toBeVisible();
await type(page, 'Today');
await linkedPagePopover
.locator(`icon-button`)
.filter({ hasText: 'Today' })
.nth(0)
.click();
};
export async function clickPageMoreActions(page: Page) { export async function clickPageMoreActions(page: Page) {
return page return page
.getByTestId('header') .getByTestId('header')