import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; import { DefaultInlineManagerExtension, type RichText, } from '@blocksuite/affine-components/rich-text'; import { TOGGLE_BUTTON_PARENT_CLASS } from '@blocksuite/affine-components/toggle-button'; import type { ParagraphBlockModel } from '@blocksuite/affine-model'; import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, NOTE_SELECTOR, } from '@blocksuite/affine-shared/consts'; import { DocModeProvider } from '@blocksuite/affine-shared/services'; import { calculateCollapsedSiblings, getNearestHeadingBefore, getViewportElement, } from '@blocksuite/affine-shared/utils'; import type { BlockComponent } from '@blocksuite/block-std'; import { getInlineRangeProvider, TextSelection } from '@blocksuite/block-std'; import type { InlineRangeProvider } from '@blocksuite/inline'; import { computed, effect, signal } from '@preact/signals-core'; import { html, nothing, type TemplateResult } from 'lit'; import { query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import type { ParagraphBlockService } from './paragraph-service.js'; import { paragraphBlockStyles } from './styles.js'; export class ParagraphBlockComponent extends CaptionedBlockComponent< ParagraphBlockModel, ParagraphBlockService > { static override styles = paragraphBlockStyles; focused$ = computed(() => { const selection = this.std.selection.value.find( selection => selection.blockId === this.model?.id ); if (!selection) return false; return selection.is(TextSelection); }); private readonly _composing = signal(false); private readonly _displayPlaceholder = signal(false); private _inlineRangeProvider: InlineRangeProvider | null = null; private readonly _isInDatabase = () => { let parent = this.parentElement; while (parent && parent !== document.body) { if (parent.tagName.toLowerCase() === 'affine-database') { return true; } parent = parent.parentElement; } return false; }; get attributeRenderer() { return this.inlineManager.getRenderer(); } get attributesSchema() { return this.inlineManager.getSchema(); } get collapsedSiblings() { return calculateCollapsedSiblings(this.model); } get embedChecker() { return this.inlineManager.embedChecker; } get inEdgelessText() { return ( this.topContenteditableElement?.tagName.toLowerCase() === 'affine-edgeless-text' ); } get inlineEditor() { return this._richTextElement?.inlineEditor; } get inlineManager() { return this.std.get(DefaultInlineManagerExtension.identifier); } override get topContenteditableElement() { if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { return this.closest(NOTE_SELECTOR); } return this.rootComponent; } override connectedCallback() { super.connectedCallback(); this.handleEvent( 'compositionStart', () => { this._composing.value = true; }, { flavour: true } ); this.handleEvent( 'compositionEnd', () => { this._composing.value = false; }, { flavour: true } ); this._inlineRangeProvider = getInlineRangeProvider(this); this.disposables.add( effect(() => { const composing = this._composing.value; if (composing || this.doc.readonly) { this._displayPlaceholder.value = false; return; } const textSelection = this.host.selection.find(TextSelection); const isCollapsed = textSelection?.isCollapsed() ?? false; if (!this.focused$.value || !isCollapsed) { this._displayPlaceholder.value = false; return; } this.updateComplete .then(() => { if ( (this.inlineEditor?.yTextLength ?? 0) > 0 || this._isInDatabase() ) { this._displayPlaceholder.value = false; return; } this._displayPlaceholder.value = true; return; }) .catch(console.error); }) ); this.disposables.add( effect(() => { const type = this.model.type$.value; if (!type.startsWith('h') && this.model.collapsed) { this.model.collapsed = false; } }) ); this.disposables.add( effect(() => { const collapsed = this.model.collapsed$.value; this._readonlyCollapsed = collapsed; // reset text selection when selected block is collapsed if (this.model.type$.value.startsWith('h') && collapsed) { const collapsedSiblings = this.collapsedSiblings; const textSelection = this.host.selection.find(TextSelection); if ( textSelection && collapsedSiblings.some( sibling => sibling.id === textSelection.blockId ) ) { this.host.selection.clear(['text']); } } }) ); // > # 123 // # 456 // // we need to update collapsed state of 123 when 456 converted to text let beforeType = this.model.type$.peek(); this.disposables.add( effect(() => { const type = this.model.type$.value; if (beforeType !== type && !type.startsWith('h')) { const nearestHeading = getNearestHeadingBefore(this.model); if ( nearestHeading && nearestHeading.type.startsWith('h') && nearestHeading.collapsed && !this.doc.readonly ) { nearestHeading.collapsed = false; } } beforeType = type; }) ); } override async getUpdateComplete() { const result = await super.getUpdateComplete(); await this._richTextElement?.updateComplete; return result; } override renderBlock(): TemplateResult<1> { const { type$ } = this.model; const collapsed = this.doc.readonly ? this._readonlyCollapsed : this.model.collapsed; const collapsedSiblings = this.collapsedSiblings; let style = html``; if (this.model.type$.value.startsWith('h') && collapsed) { style = html` `; } const children = html`
${this.renderChildren(this.model)}
`; return html` ${style}
${this.model.type$.value.startsWith('h') ? html` ` : nothing} ${this.model.type$.value.startsWith('h') && collapsedSiblings.length > 0 ? html` { if (this.doc.readonly) { this._readonlyCollapsed = value; } else { this.doc.captureSync(); this.doc.updateBlock(this.model, { collapsed: value, }); } }} > ` : nothing} getViewportElement(this.host)} > ${this.inEdgelessText ? nothing : html`
${this.service.placeholderGenerator(this.model)}
`}
${children}
`; } @state() private accessor _readonlyCollapsed = false; @query('rich-text') private accessor _richTextElement: RichText | null = null; override accessor blockContainerStyles = { margin: 'var(--affine-paragraph-margin, 10px 0)', }; }