feat(editor): add favicon, title, description support for footnote url reference (#11924)

Closes: [BS-3272](https://linear.app/affine-design/issue/BS-3272/footnote-适配-exa-api-返回结果)

## What's Changed

Add link preview data support (favicon, title, description) for URL references in footnotes:
- Store and display URL preview data in footnotes
- Add encoding/decoding support for favicon URLs
- Optimize link preview by using existing preview data when available
This commit is contained in:
donteatfriedrice
2025-04-23 11:57:25 +00:00
parent ff133d1267
commit db5eadb72a
7 changed files with 121 additions and 31 deletions

View File

@@ -2396,6 +2396,9 @@ World!
reference: { reference: {
type: 'url', type: 'url',
url: 'https://www.example.com', url: 'https://www.example.com',
favicon: 'https://www.example.com/favicon.ico',
title: 'Example Domain',
description: 'Example Domain',
}, },
}, },
}, },
@@ -2437,7 +2440,7 @@ World!
}; };
const markdown = const markdown =
'aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"https%3A%2F%2Fwww.example.com"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n'; 'aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"https%3A%2F%2Fwww.example.com","favicon":"https%3A%2F%2Fwww.example.com%2Ffavicon.ico","title":"Example Domain","description":"Example Domain"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n';
const mdAdapter = new MarkdownAdapter(createJob(), provider); const mdAdapter = new MarkdownAdapter(createJob(), provider);
const target = await mdAdapter.fromBlockSnapshot({ const target = await mdAdapter.fromBlockSnapshot({
@@ -4029,7 +4032,11 @@ hhh
}); });
describe('footnote', () => { describe('footnote', () => {
const createFootnoteBlockSnapshot = (url: string): BlockSnapshot => ({ const url = 'https://www.example.com';
const favicon = 'https://www.example.com/favicon.ico';
const title = 'Example Domain';
const description = 'Example Domain';
const blockSnapshot = {
type: 'block', type: 'block',
id: 'matchesReplaceMap[0]', id: 'matchesReplaceMap[0]',
flavour: 'affine:note', flavour: 'affine:note',
@@ -4061,6 +4068,9 @@ hhh
reference: { reference: {
type: 'url', type: 'url',
url, url,
favicon,
title,
description,
}, },
}, },
}, },
@@ -4097,15 +4107,12 @@ hhh
children: [], children: [],
}, },
], ],
}); };
test('with encoded url', async () => { test('with encoded url and favicon', async () => {
const markdown = const encodedUrl = encodeURIComponent(url);
'aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"https%3A%2F%2Fwww.example.com"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n'; const encodedFavicon = encodeURIComponent(favicon);
const markdown = `aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"${encodedUrl}","favicon":"${encodedFavicon}","title":"${title}","description":"${description}"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n`;
const blockSnapshot = createFootnoteBlockSnapshot(
'https://www.example.com'
);
const mdAdapter = new MarkdownAdapter(createJob(), provider); const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
@@ -4114,13 +4121,8 @@ hhh
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
}); });
test('with unencoded url', async () => { test('with unencoded url and favicon', async () => {
const markdown = const markdown = `aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"${url}","favicon":"${favicon}","title":"${title}","description":"${description}"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n`;
'aaa[^1][^2][^3]\n\n[^1]: {"type":"url","url":"https://www.example.com"}\n\n[^2]: {"type":"doc","docId":"deadbeef"}\n\n[^3]: {"type":"attachment","blobId":"abcdefg","fileName":"test.txt","fileType":"text/plain"}\n';
const blockSnapshot = createFootnoteBlockSnapshot(
'https://www.example.com'
);
const mdAdapter = new MarkdownAdapter(createJob(), provider); const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({

View File

@@ -50,4 +50,28 @@ describe('footnoteUrlPreprocessor', () => {
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fpath%20with%20spaces%3Fparam%3Dvalue%26another%3Dparam"}'; '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fpath%20with%20spaces%3Fparam%3Dvalue%26another%3Dparam"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected); expect(footnoteUrlPreprocessor(input)).toBe(expected);
}); });
it('should encode unencoded favicon URLs', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com","favicon":"https://example.com/icon.png"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com","favicon":"https%3A%2F%2Fexample.com%2Ficon.png"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should not encode already encoded favicon URLs', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com","favicon":"https%3A%2F%2Fexample.com%2Ficon.png"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com","favicon":"https%3A%2F%2Fexample.com%2Ficon.png"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should handle both URL and icon encoding in the same footnote', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com?param=value","favicon":"https://example.com/icon.png?size=large"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue","favicon":"https%3A%2F%2Fexample.com%2Ficon.png%3Fsize%3Dlarge"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
}); });

View File

@@ -35,20 +35,41 @@ export function footnoteUrlPreprocessor(content: string): string {
(match, reference, jsonContent) => { (match, reference, jsonContent) => {
try { try {
const footnoteData = JSON.parse(jsonContent.trim()); const footnoteData = JSON.parse(jsonContent.trim());
// If footnoteData is not an object or doesn't have url, return original content // Basic validation checks
// If the url is already encoded, return original content if (typeof footnoteData !== 'object') {
if (
typeof footnoteData !== 'object' ||
!footnoteData.url ||
isEncoded(footnoteData.url)
) {
return match; return match;
} }
return formatFootnoteDefinition(reference, { if (!footnoteData.url) {
return match;
}
// Check if URLs are already encoded
const isUrlEncoded = isEncoded(footnoteData.url);
const hasIcon = !!footnoteData.favicon;
const isIconEncoded = hasIcon && isEncoded(footnoteData.favicon);
// If both URL and icon (if present) are already encoded, return original
if (isUrlEncoded && (!hasIcon || isIconEncoded)) {
return match;
}
// Create processed data with encoded URLs
const processedData = {
...footnoteData, ...footnoteData,
url: encodeURIComponent(footnoteData.url), url: isUrlEncoded
}); ? footnoteData.url
: encodeURIComponent(footnoteData.url),
};
// Add encoded favicon if present
if (hasIcon) {
processedData.favicon = isIconEncoded
? footnoteData.favicon
: encodeURIComponent(footnoteData.favicon);
}
return formatFootnoteDefinition(reference, processedData);
} catch { } catch {
// Keep original content if JSON parsing fails // Keep original content if JSON parsing fails
return match; return match;

View File

@@ -31,6 +31,11 @@ export const footnoteReferenceDeltaToMarkdownAdapterMatcher =
clonedFootnoteReference.url clonedFootnoteReference.url
); );
} }
if (clonedFootnoteReference.favicon) {
clonedFootnoteReference.favicon = encodeURIComponent(
clonedFootnoteReference.favicon
);
}
configs.set( configs.set(
footnoteDefinitionKey, footnoteDefinitionKey,
JSON.stringify(clonedFootnoteReference) JSON.stringify(clonedFootnoteReference)

View File

@@ -26,6 +26,11 @@ export const markdownFootnoteReferenceToDeltaMatcher =
footnoteDefinitionJson.url footnoteDefinitionJson.url
); );
} }
if (footnoteDefinitionJson.favicon) {
footnoteDefinitionJson.favicon = decodeURIComponent(
footnoteDefinitionJson.favicon
);
}
const footnoteReference = FootNoteReferenceParamsSchema.parse( const footnoteReference = FootNoteReferenceParamsSchema.parse(
footnoteDefinitionJson footnoteDefinitionJson
); );

View File

@@ -31,9 +31,15 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
private readonly _isLoading$ = signal(false); private readonly _isLoading$ = signal(false);
private readonly _linkPreview$ = signal< private readonly _linkPreview$ = signal<{
{ favicon: string | undefined; title?: string } | undefined favicon: string | undefined;
>({ favicon: undefined, title: undefined }); title?: string;
description?: string;
}>({
favicon: undefined,
title: undefined,
description: undefined,
});
private readonly _prefixIcon$ = computed(() => { private readonly _prefixIcon$ = computed(() => {
const referenceType = this.footnote.reference.type; const referenceType = this.footnote.reference.type;
@@ -105,9 +111,29 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
this.abortController.abort(); this.abortController.abort();
}; };
private readonly _initLinkPreviewData = () => {
this._linkPreview$.value = {
favicon: this.footnote.reference.favicon,
title: this.footnote.reference.title,
description: this.footnote.reference.description,
};
};
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
if (this.footnote.reference.type === 'url' && this.footnote.reference.url) {
this._initLinkPreviewData();
// If the reference is a url, and the url exists
// and the link preview data is not already set, fetch the link preview data
const isTitleAndDescriptionEmpty =
!this._linkPreview$.value?.title &&
!this._linkPreview$.value?.description;
if (
this.footnote.reference.type === 'url' &&
this.footnote.reference.url &&
isTitleAndDescriptionEmpty
) {
this._isLoading$.value = true; this._isLoading$.value = true;
this.std.store this.std.store
.get(LinkPreviewerService) .get(LinkPreviewerService)
@@ -116,6 +142,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
this._linkPreview$.value = { this._linkPreview$.value = {
favicon: data.icon ?? undefined, favicon: data.icon ?? undefined,
title: data.title ?? undefined, title: data.title ?? undefined,
description: data.description ?? undefined,
}; };
}) })
.catch(console.error) .catch(console.error)

View File

@@ -65,6 +65,9 @@ export type ReferenceInfo = z.infer<typeof ReferenceInfoSchema>;
* 3. url: string - the url of the reference * 3. url: string - the url of the reference
* 4. fileName: string - the name of the attachment * 4. fileName: string - the name of the attachment
* 5. fileType: string - the type of the attachment * 5. fileType: string - the type of the attachment
* 6. favicon: string - the favicon of the url reference
* 7. title: string - the title of the url reference
* 8. description: string - the description of the url reference
*/ */
export const FootNoteReferenceParamsSchema = z.object({ export const FootNoteReferenceParamsSchema = z.object({
type: z.enum(FootNoteReferenceTypes), type: z.enum(FootNoteReferenceTypes),
@@ -73,6 +76,9 @@ export const FootNoteReferenceParamsSchema = z.object({
fileName: z.string().optional(), fileName: z.string().optional(),
fileType: z.string().optional(), fileType: z.string().optional(),
url: z.string().optional(), url: z.string().optional(),
favicon: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
}); });
export type FootNoteReferenceParams = z.infer< export type FootNoteReferenceParams = z.infer<