mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
Close [BS-2988: database block会把顶层content editable设成false](https://linear.app/affine-design/issue/BS-2988/database-block会把顶层content-editable设成false)
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
|
import { TOGGLE_BUTTON_PARENT_CLASS } from '@blocksuite/affine-components/toggle-button';
|
|
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
|
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
|
|
import type { RichText } from '@blocksuite/affine-rich-text';
|
|
import {
|
|
BLOCK_CHILDREN_CONTAINER_PADDING_LEFT,
|
|
EDGELESS_TOP_CONTENTEDITABLE_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/std';
|
|
import { TextSelection } from '@blocksuite/std';
|
|
import {
|
|
getInlineRangeProvider,
|
|
type InlineRangeProvider,
|
|
} from '@blocksuite/std/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 { ParagraphBlockConfigExtension } from './paragraph-block-config.js';
|
|
import { paragraphBlockStyles } from './styles.js';
|
|
|
|
export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBlockModel> {
|
|
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;
|
|
};
|
|
|
|
private get _placeholder() {
|
|
return this.std
|
|
.get(ParagraphBlockConfigExtension.identifier)
|
|
?.getPlaceholder(this.model);
|
|
}
|
|
|
|
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<BlockComponent>(
|
|
EDGELESS_TOP_CONTENTEDITABLE_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.props.type$.value;
|
|
if (!type.startsWith('h') && this.model.props.collapsed) {
|
|
this.model.props.collapsed = false;
|
|
}
|
|
})
|
|
);
|
|
|
|
this.disposables.add(
|
|
effect(() => {
|
|
const collapsed = this.model.props.collapsed$.value;
|
|
this._readonlyCollapsed = collapsed;
|
|
|
|
// reset text selection when selected block is collapsed
|
|
if (this.model.props.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.props.type$.peek();
|
|
this.disposables.add(
|
|
effect(() => {
|
|
const type = this.model.props.type$.value;
|
|
if (beforeType !== type && !type.startsWith('h')) {
|
|
const nearestHeading = getNearestHeadingBefore(this.model);
|
|
if (
|
|
nearestHeading &&
|
|
nearestHeading.props.type.startsWith('h') &&
|
|
nearestHeading.props.collapsed &&
|
|
!this.doc.readonly
|
|
) {
|
|
nearestHeading.props.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.props;
|
|
const collapsed = this.doc.readonly
|
|
? this._readonlyCollapsed
|
|
: this.model.props.collapsed;
|
|
const collapsedSiblings = this.collapsedSiblings;
|
|
|
|
let style = html``;
|
|
if (this.model.props.type$.value.startsWith('h') && collapsed) {
|
|
style = html`
|
|
<style>
|
|
${collapsedSiblings.map(sibling =>
|
|
unsafeHTML(`
|
|
[data-block-id="${sibling.id}"] {
|
|
display: none !important;
|
|
}
|
|
`)
|
|
)}
|
|
</style>
|
|
`;
|
|
}
|
|
|
|
const children = html`<div
|
|
class="affine-block-children-container"
|
|
style=${styleMap({
|
|
paddingLeft: `${BLOCK_CHILDREN_CONTAINER_PADDING_LEFT}px`,
|
|
display: collapsed ? 'none' : undefined,
|
|
})}
|
|
>
|
|
${this.renderChildren(this.model)}
|
|
</div>`;
|
|
|
|
return html`
|
|
${style}
|
|
<style>
|
|
.affine-paragraph-block-container[data-has-collapsed-siblings='false']
|
|
affine-paragraph-heading-icon
|
|
.heading-icon {
|
|
transform: translateX(-48px);
|
|
}
|
|
</style>
|
|
<div
|
|
class="affine-paragraph-block-container"
|
|
data-has-collapsed-siblings="${collapsedSiblings.length > 0}"
|
|
>
|
|
<div
|
|
class=${classMap({
|
|
'affine-paragraph-rich-text-wrapper': true,
|
|
[type$.value]: true,
|
|
[TOGGLE_BUTTON_PARENT_CLASS]: true,
|
|
})}
|
|
>
|
|
${this.model.props.type$.value.startsWith('h')
|
|
? html`
|
|
<affine-paragraph-heading-icon
|
|
.model=${this.model}
|
|
></affine-paragraph-heading-icon>
|
|
`
|
|
: nothing}
|
|
${this.model.props.type$.value.startsWith('h') &&
|
|
collapsedSiblings.length > 0
|
|
? html`
|
|
<blocksuite-toggle-button
|
|
.collapsed=${collapsed}
|
|
.updateCollapsed=${(value: boolean) => {
|
|
if (this.doc.readonly) {
|
|
this._readonlyCollapsed = value;
|
|
} else {
|
|
this.doc.captureSync();
|
|
this.doc.updateBlock(this.model, {
|
|
collapsed: value,
|
|
});
|
|
}
|
|
}}
|
|
></blocksuite-toggle-button>
|
|
`
|
|
: nothing}
|
|
<rich-text
|
|
.yText=${this.model.props.text.yText}
|
|
.inlineEventSource=${this.topContenteditableElement ?? nothing}
|
|
.undoManager=${this.doc.history}
|
|
.attributesSchema=${this.attributesSchema}
|
|
.attributeRenderer=${this.attributeRenderer}
|
|
.markdownMatches=${this.inlineManager?.markdownMatches}
|
|
.embedChecker=${this.embedChecker}
|
|
.readonly=${this.doc.readonly}
|
|
.inlineRangeProvider=${this._inlineRangeProvider}
|
|
.enableClipboard=${false}
|
|
.enableUndoRedo=${false}
|
|
.verticalScrollContainerGetter=${() =>
|
|
getViewportElement(this.host)}
|
|
></rich-text>
|
|
${this.inEdgelessText
|
|
? nothing
|
|
: html`
|
|
<div
|
|
contenteditable="false"
|
|
class=${classMap({
|
|
'affine-paragraph-placeholder': true,
|
|
visible: this._displayPlaceholder.value,
|
|
})}
|
|
>
|
|
${this._placeholder}
|
|
</div>
|
|
`}
|
|
</div>
|
|
|
|
${children}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
@state()
|
|
private accessor _readonlyCollapsed = false;
|
|
|
|
@query('rich-text')
|
|
private accessor _richTextElement: RichText | null = null;
|
|
|
|
override accessor blockContainerStyles = {
|
|
margin: 'var(--affine-paragraph-margin, 10px 0)',
|
|
};
|
|
}
|