feat(editor): footnote inline package (#11049)

This commit is contained in:
Saul-Mirone
2025-03-20 16:18:22 +00:00
parent e5e429e7b2
commit 57388e4cf7
29 changed files with 272 additions and 124 deletions

View File

@@ -0,0 +1,120 @@
import type { FootNote } from '@blocksuite/affine-model';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { TemplateResult } from 'lit';
type FootNoteNodeRenderer = (
footnote: FootNote,
std: BlockStdScope
) => TemplateResult<1>;
type FootNotePopupRenderer = (
footnote: FootNote,
std: BlockStdScope,
abortController: AbortController
) => TemplateResult<1>;
export type FootNotePopupClickHandler = (
footnote: FootNote,
abortController: AbortController
) => void;
export interface FootNoteNodeConfig {
customNodeRenderer?: FootNoteNodeRenderer;
customPopupRenderer?: FootNotePopupRenderer;
interactive?: boolean;
hidePopup?: boolean;
disableHoverEffect?: boolean;
onPopupClick?: FootNotePopupClickHandler;
}
export class FootNoteNodeConfigProvider {
private _customNodeRenderer?: FootNoteNodeRenderer;
private _customPopupRenderer?: FootNotePopupRenderer;
private _hidePopup: boolean;
private _interactive: boolean;
private _disableHoverEffect: boolean;
private _onPopupClick?: FootNotePopupClickHandler;
get customNodeRenderer() {
return this._customNodeRenderer;
}
get customPopupRenderer() {
return this._customPopupRenderer;
}
get onPopupClick() {
return this._onPopupClick;
}
get doc() {
return this.std.store;
}
get hidePopup() {
return this._hidePopup;
}
get interactive() {
return this._interactive;
}
get disableHoverEffect() {
return this._disableHoverEffect;
}
constructor(
config: FootNoteNodeConfig,
readonly std: BlockStdScope
) {
this._customNodeRenderer = config.customNodeRenderer;
this._customPopupRenderer = config.customPopupRenderer;
this._hidePopup = config.hidePopup ?? false;
this._interactive = config.interactive ?? true;
this._disableHoverEffect = config.disableHoverEffect ?? false;
this._onPopupClick = config.onPopupClick;
}
setCustomNodeRenderer(renderer: FootNoteNodeRenderer) {
this._customNodeRenderer = renderer;
}
setCustomPopupRenderer(renderer: FootNotePopupRenderer) {
this._customPopupRenderer = renderer;
}
setHidePopup(hidePopup: boolean) {
this._hidePopup = hidePopup;
}
setInteractive(interactive: boolean) {
this._interactive = interactive;
}
setDisableHoverEffect(disableHoverEffect: boolean) {
this._disableHoverEffect = disableHoverEffect;
}
setPopupClick(onPopupClick: FootNotePopupClickHandler) {
this._onPopupClick = onPopupClick;
}
}
export const FootNoteNodeConfigIdentifier =
createIdentifier<FootNoteNodeConfigProvider>('AffineFootNoteNodeConfig');
export function FootNoteNodeConfigExtension(
config: FootNoteNodeConfig
): ExtensionType {
return {
setup: di => {
di.addImpl(
FootNoteNodeConfigIdentifier,
provider =>
new FootNoteNodeConfigProvider(config, provider.get(StdIdentifier))
);
},
};
}

View File

@@ -0,0 +1,204 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import type { FootNote } from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
BlockSelection,
type BlockStdScope,
ShadowlessElement,
TextSelection,
} from '@blocksuite/block-std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
ZERO_WIDTH_NON_JOINER,
ZERO_WIDTH_SPACE,
} from '@blocksuite/block-std/inline';
import { WithDisposable } from '@blocksuite/global/lit';
import type { DeltaInsert } from '@blocksuite/store';
import { shift } from '@floating-ui/dom';
import { baseTheme } from '@toeverything/theme';
import { css, html, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit-html/directives/class-map.js';
import { ref } from 'lit-html/directives/ref.js';
import type { FootNoteNodeConfigProvider } from './footnote-config';
// Virtual padding for the footnote popup overflow detection offsets.
const POPUP_SHIFT_PADDING = 8;
export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
static override styles = css`
.footnote-node {
padding: 0 2px;
user-select: none;
cursor: pointer;
}
.footnote-node {
.footnote-content-default {
display: inline-block;
background: ${unsafeCSSVarV2('block/footnote/numberBgHover')};
color: ${unsafeCSSVarV2('button/pureWhiteText')};
width: 14px;
height: 14px;
line-height: 14px;
font-size: 10px;
font-weight: 400;
border-radius: 50%;
text-align: center;
text-overflow: ellipsis;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
transition: background 0.3s ease-in-out;
}
}
.footnote-node.hover-effect {
.footnote-content-default {
color: var(--affine-text-primary-color);
background: ${unsafeCSSVarV2('block/footnote/numberBg')};
}
}
.footnote-node.hover-effect:hover {
.footnote-content-default {
color: ${unsafeCSSVarV2('button/pureWhiteText')};
background: ${unsafeCSSVarV2('block/footnote/numberBgHover')};
}
}
`;
get customNodeRenderer() {
return this.config?.customNodeRenderer;
}
get customPopupRenderer() {
return this.config?.customPopupRenderer;
}
get interactive() {
return this.config?.interactive;
}
get hidePopup() {
return this.config?.hidePopup;
}
get disableHoverEffect() {
return this.config?.disableHoverEffect;
}
get onPopupClick() {
return this.config?.onPopupClick;
}
get inlineEditor() {
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
`[${INLINE_ROOT_ATTR}]`
);
return inlineRoot?.inlineEditor;
}
get selfInlineRange() {
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
return selfInlineRange;
}
private readonly _FootNoteDefaultContent = (footnote: FootNote) => {
return html`<span class="footnote-content-default"
>${footnote.label}</span
>`;
};
private readonly _FootNotePopup = (
footnote: FootNote,
abortController: AbortController
) => {
return this.customPopupRenderer
? this.customPopupRenderer(footnote, this.std, abortController)
: html`<footnote-popup
.footnote=${footnote}
.std=${this.std}
.abortController=${abortController}
.onPopupClick=${this.onPopupClick}
></footnote-popup>`;
};
private readonly _whenHover: HoverController = new HoverController(
this,
({ abortController }) => {
const footnote = this.delta.attributes?.footnote;
if (!footnote) return null;
if (
this.config?.hidePopup ||
!this.selfInlineRange ||
!this.inlineEditor
) {
return null;
}
const selection = this.std?.selection;
if (!selection) {
return null;
}
const textSelection = selection.find(TextSelection);
if (!!textSelection && !textSelection.isCollapsed()) {
return null;
}
const blockSelections = selection.filter(BlockSelection);
if (blockSelections.length) {
return null;
}
return {
template: this._FootNotePopup(footnote, abortController),
container: this,
computePosition: {
referenceElement: this,
placement: 'top',
autoUpdate: true,
middleware: [shift({ padding: POPUP_SHIFT_PADDING })],
},
};
},
{ enterDelay: 300 }
);
override render() {
const attributes = this.delta.attributes;
const footnote = attributes?.footnote;
if (!footnote) {
return nothing;
}
const node = this.customNodeRenderer
? this.customNodeRenderer(footnote, this.std)
: this._FootNoteDefaultContent(footnote);
const nodeClasses = classMap({
'footnote-node': true,
'hover-effect': !this.disableHoverEffect,
});
return html`<span
${this.hidePopup ? '' : ref(this._whenHover.setReference)}
class=${nodeClasses}
>${node}<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text
></span>`;
}
@property({ attribute: false })
accessor config: FootNoteNodeConfigProvider | undefined = undefined;
@property({ type: Object })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
attributes: {},
};
@property({ attribute: false })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,95 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
export class FootNotePopupChip extends LitElement {
static override styles = css`
.popup-chip-container {
display: flex;
border-radius: 4px;
max-width: 173px;
height: 24px;
padding: 2px 4px;
align-items: center;
gap: 4px;
box-sizing: border-box;
cursor: default;
transition: width 0.3s ease-in-out;
}
.prefix-icon,
.suffix-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
border-radius: 4px;
svg,
object {
width: 16px;
height: 16px;
fill: ${unsafeCSSVarV2('icon/primary')};
}
}
.suffix-icon:hover {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
cursor: pointer;
}
.popup-chip-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
height: 20px;
line-height: 20px;
color: ${unsafeCSSVarV2('text/primary')};
font-size: 12px;
font-weight: 400;
}
`;
override render() {
return html`
<div class="popup-chip-container" @click=${this.onClick}>
${this.prefixIcon
? html`<div class="prefix-icon" @click=${this.onPrefixClick}>
${this.prefixIcon}
</div>`
: nothing}
<div class="popup-chip-label" title=${this.tooltip}>${this.label}</div>
${this.suffixIcon
? html`<div class="suffix-icon" @click=${this.onSuffixClick}>
${this.suffixIcon}
</div>`
: nothing}
</div>
`;
}
@property({ attribute: false })
accessor prefixIcon: TemplateResult | undefined = undefined;
@property({ attribute: false })
accessor label: string = '';
@property({ attribute: false })
accessor suffixIcon: TemplateResult | undefined = undefined;
@property({ attribute: false })
accessor tooltip: string = '';
@property({ attribute: false })
accessor onClick: (() => void) | undefined = undefined;
@property({ attribute: false })
accessor onPrefixClick: (() => void) | undefined = undefined;
@property({ attribute: false })
accessor onSuffixClick: (() => void) | undefined = undefined;
}

View File

@@ -0,0 +1,221 @@
import {
DarkLoadingIcon,
getAttachmentFileIcon,
LightLoadingIcon,
WebIcon16,
} from '@blocksuite/affine-components/icons';
import { PeekViewProvider } from '@blocksuite/affine-components/peek';
import { ColorScheme, type FootNote } from '@blocksuite/affine-model';
import {
DocDisplayMetaProvider,
LinkPreviewerService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { BlockStdScope } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DualLinkIcon, LinkIcon } from '@blocksuite/icons/lit';
import { computed, signal } from '@preact/signals-core';
import { css, html, LitElement, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import type { FootNotePopupClickHandler } from './footnote-config';
export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
.footnote-popup-container {
border-radius: 4px;
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
background-color: ${unsafeCSSVarV2('layer/background/primary')};
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
`;
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;
if (referenceType === 'doc') {
const docId = this.footnote.reference.docId;
if (!docId) {
return undefined;
}
return this.std.get(DocDisplayMetaProvider).icon(docId).value;
} else if (referenceType === 'attachment') {
const fileType = this.footnote.reference.fileType;
if (!fileType) {
return undefined;
}
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;
});
private readonly _suffixIcon = (): TemplateResult | undefined => {
const referenceType = this.footnote.reference.type;
if (referenceType === 'doc') {
return DualLinkIcon({ width: '16px', height: '16px' });
} else if (referenceType === 'url') {
return LinkIcon({ width: '16px', height: '16px' });
}
return undefined;
};
private readonly _popupLabel$ = computed(() => {
const referenceType = this.footnote.reference.type;
let label = '';
const { docId, fileName, url } = this.footnote.reference;
switch (referenceType) {
case 'doc':
if (!docId) {
return label;
}
label = this.std.get(DocDisplayMetaProvider).title(docId).value;
break;
case 'attachment':
if (!fileName) {
return label;
}
label = fileName;
break;
case 'url':
if (!url) {
return label;
}
label = this._linkPreview$.value?.title ?? url;
break;
}
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;
};
/**
* When clicking the chip, we will navigate to the reference doc or open the url
*/
private readonly _handleDocReference = (docId: string) => {
this.std
.getOptional(PeekViewProvider)
?.peek({
docId,
})
.catch(console.error);
};
private readonly _handleUrlReference = (url: string) => {
window.open(url, '_blank');
};
private readonly _handleReference = () => {
const { type, docId, url } = this.footnote.reference;
switch (type) {
case 'doc':
if (docId) {
this._handleDocReference(docId);
}
break;
case 'url':
if (url) {
this._handleUrlReference(url);
}
break;
}
this.abortController.abort();
};
private readonly _onChipClick = () => {
// If the onPopupClick is defined, use it
if (this.onPopupClick) {
this.onPopupClick(this.footnote, this.abortController);
return;
}
// Otherwise, handle the reference by default
this._handleReference();
};
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() {
return html`
<div class="footnote-popup-container">
<footnote-popup-chip
.prefixIcon=${this._prefixIcon$.value}
.label=${this._popupLabel$.value}
.suffixIcon=${this._suffixIcon()}
.onClick=${this._onChipClick}
.tooltip=${this._tooltip$.value}
></footnote-popup-chip>
</div>
`;
}
@property({ attribute: false })
accessor footnote!: FootNote;
@property({ attribute: false })
accessor std!: BlockStdScope;
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor onPopupClick: FootNotePopupClickHandler | undefined = undefined;
}