feat(editor): add link preview to footnote popup (#9869)

[BS-2399](https://linear.app/affine-design/issue/BS-2399/ai-link-的预览支持:获取-fav-icon-标题)
This commit is contained in:
donteatfriedrice
2025-01-23 09:31:20 +00:00
parent 02bcecde72
commit bdc8dd8d5f
4 changed files with 93 additions and 15 deletions

View File

@@ -173,7 +173,7 @@ export const markdownFootnoteReferenceToDeltaMatcher =
}; };
return [{ insert: ' ', attributes: { footnote } }]; return [{ insert: ' ', attributes: { footnote } }];
} catch (error) { } catch (error) {
console.error('Error parsing footnote reference', error); console.warn('Error parsing footnote reference', error);
return []; return [];
} }
}, },

View File

@@ -15,6 +15,7 @@ import {
ZERO_WIDTH_NON_JOINER, ZERO_WIDTH_NON_JOINER,
ZERO_WIDTH_SPACE, ZERO_WIDTH_SPACE,
} from '@blocksuite/inline'; } from '@blocksuite/inline';
import { shift } from '@floating-ui/dom';
import { baseTheme } from '@toeverything/theme'; import { baseTheme } from '@toeverything/theme';
import { css, html, nothing, unsafeCSS } from 'lit'; import { css, html, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
@@ -129,6 +130,7 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
referenceElement: this, referenceElement: this,
placement: 'top', placement: 'top',
autoUpdate: true, autoUpdate: true,
middleware: [shift()],
}, },
}; };
}, },

View File

@@ -26,9 +26,11 @@ export class FootNotePopupChip extends LitElement {
color: ${unsafeCSSVarV2('icon/primary')}; color: ${unsafeCSSVarV2('icon/primary')};
border-radius: 4px; border-radius: 4px;
svg { svg,
object {
width: 16px; width: 16px;
height: 16px; height: 16px;
fill: ${unsafeCSSVarV2('icon/primary')};
} }
} }
@@ -59,7 +61,7 @@ export class FootNotePopupChip extends LitElement {
${this.prefixIcon} ${this.prefixIcon}
</div>` </div>`
: nothing} : nothing}
<div class="popup-chip-label">${this.label}</div> <div class="popup-chip-label" title=${this.tooltip}>${this.label}</div>
${this.suffixIcon ${this.suffixIcon
? html`<div class="suffix-icon" @click=${this.onSuffixClick}> ? html`<div class="suffix-icon" @click=${this.onSuffixClick}>
${this.suffixIcon} ${this.suffixIcon}
@@ -78,6 +80,9 @@ export class FootNotePopupChip extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
accessor suffixIcon: TemplateResult | undefined = undefined; accessor suffixIcon: TemplateResult | undefined = undefined;
@property({ attribute: false })
accessor tooltip: string = '';
@property({ attribute: false }) @property({ attribute: false })
accessor onClick: (() => void) | undefined = undefined; accessor onClick: (() => void) | undefined = undefined;

View File

@@ -1,16 +1,26 @@
import type { FootNote } from '@blocksuite/affine-model'; import { ColorScheme, type FootNote } from '@blocksuite/affine-model';
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services'; import {
DocDisplayMetaProvider,
LinkPreviewerService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { BlockStdScope } from '@blocksuite/block-std'; import type { BlockStdScope } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { DualLinkIcon, LinkIcon } from '@blocksuite/icons/lit'; import { DualLinkIcon, LinkIcon } from '@blocksuite/icons/lit';
import { computed, signal } from '@preact/signals-core';
import { css, html, LitElement, type TemplateResult } from 'lit'; import { css, html, LitElement, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import { getAttachmentFileIcon } from '../../../../../icons'; import {
DarkLoadingIcon,
getAttachmentFileIcon,
LightLoadingIcon,
WebIcon16,
} from '../../../../../icons';
import { RefNodeSlotsProvider } from '../../../../extension/ref-node-slots'; import { RefNodeSlotsProvider } from '../../../../extension/ref-node-slots';
export class FootNotePopup extends WithDisposable(LitElement) { export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css` static override styles = css`
.footnote-popup-container { .footnote-popup-container {
border-radius: 4px; border-radius: 4px;
@@ -21,7 +31,13 @@ export class FootNotePopup extends WithDisposable(LitElement) {
} }
`; `;
private readonly _prefixIcon = () => { private readonly _isLoading$ = signal(false);
private readonly _linkPreview$ = signal<
{ favicon: string | undefined; title?: string } | undefined
>({ favicon: undefined, title: undefined });
private readonly _prefixIcon$ = computed(() => {
const referenceType = this.footnote.reference.type; const referenceType = this.footnote.reference.type;
if (referenceType === 'doc') { if (referenceType === 'doc') {
const docId = this.footnote.reference.docId; const docId = this.footnote.reference.docId;
@@ -35,9 +51,31 @@ export class FootNotePopup extends WithDisposable(LitElement) {
return undefined; return undefined;
} }
return getAttachmentFileIcon(fileType); return getAttachmentFileIcon(fileType);
} else if (referenceType === 'url') {
if (this._isLoading$.value) {
return this._LoadingIcon();
}
const favicon = this._linkPreview$.value?.favicon;
if (!favicon) {
return undefined;
}
const titleIconType =
favicon.split('.').pop() === 'svg'
? 'svg+xml'
: favicon.split('.').pop();
const titleIcon = html`<object
type="image/${titleIconType}"
data=${favicon}
draggable="false"
>
${WebIcon16}
</object>`;
return titleIcon;
} }
return undefined; return undefined;
}; });
private readonly _suffixIcon = (): TemplateResult | undefined => { private readonly _suffixIcon = (): TemplateResult | undefined => {
const referenceType = this.footnote.reference.type; const referenceType = this.footnote.reference.type;
@@ -49,7 +87,7 @@ export class FootNotePopup extends WithDisposable(LitElement) {
return undefined; return undefined;
}; };
private readonly _popupLabel = () => { private readonly _popupLabel$ = computed(() => {
const referenceType = this.footnote.reference.type; const referenceType = this.footnote.reference.type;
let label = ''; let label = '';
const { docId, fileName, url } = this.footnote.reference; const { docId, fileName, url } = this.footnote.reference;
@@ -70,11 +108,23 @@ export class FootNotePopup extends WithDisposable(LitElement) {
if (!url) { if (!url) {
return label; return label;
} }
// TODO(@chen): get url title from url, need to implement after LinkPreviewer refactored as an extension label = this._linkPreview$.value?.title ?? url;
label = url;
break; break;
} }
return label; return label;
});
private readonly _tooltip$ = computed(() => {
const referenceType = this.footnote.reference.type;
if (referenceType === 'url') {
return this.footnote.reference.url ?? '';
}
return this._popupLabel$.value;
});
private readonly _LoadingIcon = () => {
const theme = this.std.get(ThemeProvider).theme;
return theme === ColorScheme.Light ? LightLoadingIcon : DarkLoadingIcon;
}; };
/** /**
@@ -103,14 +153,35 @@ export class FootNotePopup extends WithDisposable(LitElement) {
this.abortController.abort(); this.abortController.abort();
}; };
override connectedCallback() {
super.connectedCallback();
if (this.footnote.reference.type === 'url' && this.footnote.reference.url) {
this._isLoading$.value = true;
this.std.store
.get(LinkPreviewerService)
.query(this.footnote.reference.url)
.then(data => {
this._linkPreview$.value = {
favicon: data.icon ?? undefined,
title: data.title ?? undefined,
};
})
.catch(console.error)
.finally(() => {
this._isLoading$.value = false;
});
}
}
override render() { override render() {
return html` return html`
<div class="footnote-popup-container"> <div class="footnote-popup-container">
<footnote-popup-chip <footnote-popup-chip
.prefixIcon=${this._prefixIcon()} .prefixIcon=${this._prefixIcon$.value}
.label=${this._popupLabel()} .label=${this._popupLabel$.value}
.suffixIcon=${this._suffixIcon()} .suffixIcon=${this._suffixIcon()}
.onClick=${this._onChipClick} .onClick=${this._onChipClick}
.tooltip=${this._tooltip$.value}
></footnote-popup-chip> ></footnote-popup-chip>
</div> </div>
`; `;