From db5eadb72a1a4f95828812ecd55774dc398c7fc9 Mon Sep 17 00:00:00 2001 From: donteatfriedrice Date: Wed, 23 Apr 2025 11:57:25 +0000 Subject: [PATCH] feat(editor): add favicon, title, description support for footnote url reference (#11924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/adapters/markdown.unit.spec.ts | 36 ++++++++-------- .../adapters/preprocessor.unit.spec.ts | 24 +++++++++++ .../src/adapters/markdown/preprocessor.ts | 41 ++++++++++++++----- .../src/adapters/markdown/inline-delta.ts | 5 +++ .../src/adapters/markdown/markdown-inline.ts | 5 +++ .../src/footnote-node/footnote-popup.ts | 35 ++++++++++++++-- blocksuite/affine/model/src/consts/doc.ts | 6 +++ 7 files changed, 121 insertions(+), 31 deletions(-) diff --git a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts index a82dff552..6d1059cec 100644 --- a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts @@ -2396,6 +2396,9 @@ World! reference: { type: 'url', 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 = - '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 target = await mdAdapter.fromBlockSnapshot({ @@ -4029,7 +4032,11 @@ hhh }); 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', id: 'matchesReplaceMap[0]', flavour: 'affine:note', @@ -4061,6 +4068,9 @@ hhh reference: { type: 'url', url, + favicon, + title, + description, }, }, }, @@ -4097,15 +4107,12 @@ hhh children: [], }, ], - }); + }; - test('with encoded url', async () => { - 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'; - - const blockSnapshot = createFootnoteBlockSnapshot( - 'https://www.example.com' - ); + test('with encoded url and favicon', async () => { + const encodedUrl = encodeURIComponent(url); + 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 mdAdapter = new MarkdownAdapter(createJob(), provider); const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ @@ -4114,13 +4121,8 @@ hhh expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); }); - test('with unencoded url', async () => { - const markdown = - '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' - ); + test('with unencoded url and favicon', async () => { + 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`; const mdAdapter = new MarkdownAdapter(createJob(), provider); const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ diff --git a/blocksuite/affine/blocks/bookmark/src/__tests__/adapters/preprocessor.unit.spec.ts b/blocksuite/affine/blocks/bookmark/src/__tests__/adapters/preprocessor.unit.spec.ts index 8cb3eff1e..d9e10f3a9 100644 --- a/blocksuite/affine/blocks/bookmark/src/__tests__/adapters/preprocessor.unit.spec.ts +++ b/blocksuite/affine/blocks/bookmark/src/__tests__/adapters/preprocessor.unit.spec.ts @@ -50,4 +50,28 @@ describe('footnoteUrlPreprocessor', () => { '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fpath%20with%20spaces%3Fparam%3Dvalue%26another%3Dparam"}'; 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); + }); }); diff --git a/blocksuite/affine/blocks/bookmark/src/adapters/markdown/preprocessor.ts b/blocksuite/affine/blocks/bookmark/src/adapters/markdown/preprocessor.ts index 2596dbf66..6520b86c4 100644 --- a/blocksuite/affine/blocks/bookmark/src/adapters/markdown/preprocessor.ts +++ b/blocksuite/affine/blocks/bookmark/src/adapters/markdown/preprocessor.ts @@ -35,20 +35,41 @@ export function footnoteUrlPreprocessor(content: string): string { (match, reference, jsonContent) => { try { const footnoteData = JSON.parse(jsonContent.trim()); - // If footnoteData is not an object or doesn't have url, return original content - // If the url is already encoded, return original content - if ( - typeof footnoteData !== 'object' || - !footnoteData.url || - isEncoded(footnoteData.url) - ) { + // Basic validation checks + if (typeof footnoteData !== 'object') { 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, - 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 { // Keep original content if JSON parsing fails return match; diff --git a/blocksuite/affine/inlines/footnote/src/adapters/markdown/inline-delta.ts b/blocksuite/affine/inlines/footnote/src/adapters/markdown/inline-delta.ts index bbef17df0..647b801c4 100644 --- a/blocksuite/affine/inlines/footnote/src/adapters/markdown/inline-delta.ts +++ b/blocksuite/affine/inlines/footnote/src/adapters/markdown/inline-delta.ts @@ -31,6 +31,11 @@ export const footnoteReferenceDeltaToMarkdownAdapterMatcher = clonedFootnoteReference.url ); } + if (clonedFootnoteReference.favicon) { + clonedFootnoteReference.favicon = encodeURIComponent( + clonedFootnoteReference.favicon + ); + } configs.set( footnoteDefinitionKey, JSON.stringify(clonedFootnoteReference) diff --git a/blocksuite/affine/inlines/footnote/src/adapters/markdown/markdown-inline.ts b/blocksuite/affine/inlines/footnote/src/adapters/markdown/markdown-inline.ts index 8dd841029..bc82198ba 100644 --- a/blocksuite/affine/inlines/footnote/src/adapters/markdown/markdown-inline.ts +++ b/blocksuite/affine/inlines/footnote/src/adapters/markdown/markdown-inline.ts @@ -26,6 +26,11 @@ export const markdownFootnoteReferenceToDeltaMatcher = footnoteDefinitionJson.url ); } + if (footnoteDefinitionJson.favicon) { + footnoteDefinitionJson.favicon = decodeURIComponent( + footnoteDefinitionJson.favicon + ); + } const footnoteReference = FootNoteReferenceParamsSchema.parse( footnoteDefinitionJson ); diff --git a/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts index 6bd3d4a99..d0d7f2aa7 100644 --- a/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts +++ b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts @@ -31,9 +31,15 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) { private readonly _isLoading$ = signal(false); - private readonly _linkPreview$ = signal< - { favicon: string | undefined; title?: string } | undefined - >({ favicon: undefined, title: undefined }); + private readonly _linkPreview$ = signal<{ + favicon: string | undefined; + title?: string; + description?: string; + }>({ + favicon: undefined, + title: undefined, + description: undefined, + }); private readonly _prefixIcon$ = computed(() => { const referenceType = this.footnote.reference.type; @@ -105,9 +111,29 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) { 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() { 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.std.store .get(LinkPreviewerService) @@ -116,6 +142,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) { this._linkPreview$.value = { favicon: data.icon ?? undefined, title: data.title ?? undefined, + description: data.description ?? undefined, }; }) .catch(console.error) diff --git a/blocksuite/affine/model/src/consts/doc.ts b/blocksuite/affine/model/src/consts/doc.ts index 64542139e..6f60a14ea 100644 --- a/blocksuite/affine/model/src/consts/doc.ts +++ b/blocksuite/affine/model/src/consts/doc.ts @@ -65,6 +65,9 @@ export type ReferenceInfo = z.infer; * 3. url: string - the url of the reference * 4. fileName: string - the name 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({ type: z.enum(FootNoteReferenceTypes), @@ -73,6 +76,9 @@ export const FootNoteReferenceParamsSchema = z.object({ fileName: z.string().optional(), fileType: z.string().optional(), url: z.string().optional(), + favicon: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), }); export type FootNoteReferenceParams = z.infer<