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:
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
Reference in New Issue
Block a user