-
-
Notifications
You must be signed in to change notification settings - Fork 3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(editor): add affine inline footnote (#9745)
[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
1 parent
7d1d167
commit df910d7
Showing
10 changed files
with
558 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
93 changes: 93 additions & 0 deletions
93
...ite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-config.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
); | ||
}, | ||
}; | ||
} |
167 changes: 167 additions & 0 deletions
167
...suite/affine/components/src/rich-text/inline/presets/nodes/footnote-node/footnote-node.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.