Skip to content

Commit

Permalink
feat(editor): add affine inline footnote (#9745)
Browse files Browse the repository at this point in the history
[BS-2369](https://linear.app/affine-design/issue/BS-2369/新增-affinetextattribute-footnote)  [BS-2370](https://linear.app/affine-design/issue/BS-2370/支持-footnote-自定义渲染行内内容) [BS-2372](https://linear.app/affine-design/issue/BS-2372/提供-footnoteconfigextension) [BS-2375](https://linear.app/affine-design/issue/BS-2375/footnote-自定义渲染-popup)

### Add new AffineTextAttribute: footnote

```
/**
 * FootNote is used to reference a doc, attachment or url.
 */
export interface AffineTextAttributes {
  ...
  footnote?: {
    label: string; // label of the footnote
    reference: {
      type: 'doc' | 'attachment' | 'url'; // type of reference
      docId?: string; // the id of the reference doc
      url?: string; //  the url of the reference network resource
      blobId?: string; // the id of the reference attachment
      fileName?: string; // the name of the reference attachment
      fileType?: string; // the type of the reference attachment
    }
  } | null
}
```

### FootNoteNodeConfigProvider Extension

#### FootNoteNodeConfig Type Definition

```
type FootNoteNodeRenderer = (
  footnote: FootNote,
  std: BlockStdScope
) => TemplateResult<1>;

type FootNotePopupRenderer = (
  footnote: FootNote,
  std: BlockStdScope,
  abortController: AbortController
) => TemplateResult<1>;

export interface FootNoteNodeConfig {
  customNodeRenderer?: FootNoteNodeRenderer;
  customPopupRenderer?: FootNotePopupRenderer;
  interactive?: boolean;
  hidePopup?: boolean;
}
```

#### FootNoteNodeConfigProvider Class

```
export class FootNoteNodeConfigProvider {
  private _customNodeRenderer?: FootNoteNodeRenderer;
  private _customPopupRenderer?: FootNotePopupRenderer;
  private _hidePopup: boolean;
  private _interactive: boolean;

  get customNodeRenderer() {
    return this._customNodeRenderer;
  }

  get customPopupRenderer() {
    return this._customPopupRenderer;
  }

  get doc() {
    return this.std.store;
  }

  get hidePopup() {
    return this._hidePopup;
  }

  get interactive() {
    return this._interactive;
  }

  constructor(
    config: FootNoteNodeConfig,
    readonly std: BlockStdScope
  ) {
    this._customNodeRenderer = config.customNodeRenderer;
    this._customPopupRenderer = config.customPopupRenderer;
    this._hidePopup = config.hidePopup ?? false;
    this._interactive = config.interactive ?? true;
  }

  setCustomNodeRenderer(renderer: FootNoteNodeRenderer) {
    this._customNodeRenderer = renderer;
  }

  setCustomPopupRenderer(renderer: FootNotePopupRenderer) {
    this._customPopupRenderer = renderer;
  }

  setHidePopup(hidePopup: boolean) {
    this._hidePopup = hidePopup;
  }

  setInteractive(interactive: boolean) {
    this._interactive = interactive;
  }
}
```

#### FootNoteNodeConfigProvider Extension

```
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))
      );
    },
  };
}
```

The footnote node can be extended by this extension.

### FootnoteInlineSpec

```
export const FootNoteInlineSpecExtension = InlineSpecExtension(
  'footnote',
  provider => {
    const std = provider.get(StdIdentifier);
    const config =
      provider.getOptional(FootNoteNodeConfigIdentifier) ?? undefined;
    return {
      name: 'footnote',
      schema: FootNoteSchema.optional().nullable().catch(undefined),
      match: delta => {
        return !!delta.attributes?.footnote;
      },
      renderer: ({ delta }) => {
        return html`<affine-footnote-node
          .delta=${delta}
          .std=${std}
          .config=${config}
        ></affine-footnote-node>`;
      },
      embed: true,
    };
  }
);
```
  • Loading branch information
donteatfriedrice committed Jan 17, 2025
1 parent 7d1d167 commit df910d7
Show file tree
Hide file tree
Showing 10 changed files with 558 additions and 3 deletions.
2 changes: 2 additions & 0 deletions blocksuite/affine/components/src/rich-text/all-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
BoldInlineSpecExtension,
CodeInlineSpecExtension,
ColorInlineSpecExtension,
FootNoteInlineSpecExtension,
InlineAdapterExtensions,
InlineSpecExtensions,
ItalicInlineSpecExtension,
Expand All @@ -31,6 +32,7 @@ export const DefaultInlineManagerExtension = InlineManagerExtension({
LatexInlineSpecExtension.identifier,
ReferenceInlineSpecExtension.identifier,
LinkInlineSpecExtension.identifier,
FootNoteInlineSpecExtension.identifier,
],
});

Expand Down
14 changes: 13 additions & 1 deletion blocksuite/affine/components/src/rich-text/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ import type {
toggleTextStyleCommand,
toggleUnderline,
} from './format/text-style.js';
import { AffineLink, AffineReference } from './inline/index.js';
import {
AffineFootnoteNode,
AffineLink,
AffineReference,
} from './inline/index.js';
import { AffineText } from './inline/presets/nodes/affine-text.js';
import { FootNotePopup } from './inline/presets/nodes/footnote-node/footnote-popup.js';
import { FootNotePopupChip } from './inline/presets/nodes/footnote-node/footnote-popup-chip.js';
import { LatexEditorMenu } from './inline/presets/nodes/latex-node/latex-editor-menu.js';
import { LatexEditorUnit } from './inline/presets/nodes/latex-node/latex-editor-unit.js';
import { AffineLatexNode } from './inline/presets/nodes/latex-node/latex-node.js';
Expand All @@ -37,12 +43,18 @@ export function effects() {
customElements.define('reference-popup', ReferencePopup);
customElements.define('reference-alias-popup', ReferenceAliasPopup);
customElements.define('affine-reference', AffineReference);
customElements.define('affine-footnote-node', AffineFootnoteNode);
customElements.define('footnote-popup', FootNotePopup);
customElements.define('footnote-popup-chip', FootNotePopupChip);
}

declare global {
interface HTMLElementTagNameMap {
'affine-latex-node': AffineLatexNode;
'affine-reference': AffineReference;
'affine-footnote-node': AffineFootnoteNode;
'footnote-popup': FootNotePopup;
'footnote-popup-chip': FootNotePopupChip;
'affine-link': AffineLink;
'affine-text': AffineText;
'rich-text': RichText;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ReferenceInfoSchema } from '@blocksuite/affine-model';
import { FootNoteSchema, ReferenceInfoSchema } from '@blocksuite/affine-model';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { StdIdentifier } from '@blocksuite/block-std';
import type { InlineEditor, InlineRootElement } from '@blocksuite/inline';
import { html } from 'lit';
import { z } from 'zod';

import { InlineSpecExtension } from '../../extension/index.js';
import { FootNoteNodeConfigIdentifier } from './nodes/footnote-node/footnote-config.js';
import {
ReferenceNodeConfigIdentifier,
ReferenceNodeConfigProvider,
Expand Down Expand Up @@ -178,6 +179,30 @@ export const LatexEditorUnitSpecExtension = InlineSpecExtension({
},
});

export const FootNoteInlineSpecExtension = InlineSpecExtension(
'footnote',
provider => {
const std = provider.get(StdIdentifier);
const config =
provider.getOptional(FootNoteNodeConfigIdentifier) ?? undefined;
return {
name: 'footnote',
schema: FootNoteSchema.optional().nullable().catch(undefined),
match: delta => {
return !!delta.attributes?.footnote;
},
renderer: ({ delta }) => {
return html`<affine-footnote-node
.delta=${delta}
.std=${std}
.config=${config}
></affine-footnote-node>`;
},
embed: true,
};
}
);

export const InlineSpecExtensions = [
BoldInlineSpecExtension,
ItalicInlineSpecExtension,
Expand All @@ -190,4 +215,5 @@ export const InlineSpecExtensions = [
ReferenceInlineSpecExtension,
LinkInlineSpecExtension,
LatexEditorUnitSpecExtension,
FootNoteInlineSpecExtension,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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 interface FootNoteNodeConfig {
customNodeRenderer?: FootNoteNodeRenderer;
customPopupRenderer?: FootNotePopupRenderer;
interactive?: boolean;
hidePopup?: boolean;
}

export class FootNoteNodeConfigProvider {
private _customNodeRenderer?: FootNoteNodeRenderer;
private _customPopupRenderer?: FootNotePopupRenderer;
private _hidePopup: boolean;
private _interactive: boolean;

get customNodeRenderer() {
return this._customNodeRenderer;
}

get customPopupRenderer() {
return this._customPopupRenderer;
}

get doc() {
return this.std.store;
}

get hidePopup() {
return this._hidePopup;
}

get interactive() {
return this._interactive;
}

constructor(
config: FootNoteNodeConfig,
readonly std: BlockStdScope
) {
this._customNodeRenderer = config.customNodeRenderer;
this._customPopupRenderer = config.customPopupRenderer;
this._hidePopup = config.hidePopup ?? false;
this._interactive = config.interactive ?? true;
}

setCustomNodeRenderer(renderer: FootNoteNodeRenderer) {
this._customNodeRenderer = renderer;
}

setCustomPopupRenderer(renderer: FootNotePopupRenderer) {
this._customPopupRenderer = renderer;
}

setHidePopup(hidePopup: boolean) {
this._hidePopup = hidePopup;
}

setInteractive(interactive: boolean) {
this._interactive = interactive;
}
}

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))
);
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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 { WithDisposable } from '@blocksuite/global/utils';
import {
type DeltaInsert,
INLINE_ROOT_ATTR,
type InlineRootElement,
ZERO_WIDTH_NON_JOINER,
ZERO_WIDTH_SPACE,
} from '@blocksuite/inline';
import { baseTheme } from '@toeverything/theme';
import { css, html, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { ref } from 'lit-html/directives/ref.js';

import { HoverController } from '../../../../../hover/controller';
import type { FootNoteNodeConfigProvider } from './footnote-config';

export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
static override styles = css`
.footnote-node {
padding: 0 2px;
user-select: none;
cursor: pointer;
}
.footnote-content-default {
display: inline-block;
background: ${unsafeCSSVarV2('button/primary')};
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)};
}
`;

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 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}
></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,
},
};
},
{ enterDelay: 500 }
);

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);

return html`<span
${this.hidePopup ? '' : ref(this._whenHover.setReference)}
class="footnote-node"
>${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;
}
Loading

0 comments on commit df910d7

Please sign in to comment.