import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { type BlockModel, Blocks, type BlockViewType } from '@blocksuite/store'; import { consume, provide } from '@lit/context'; import { computed } from '@preact/signals-core'; import { nothing, type TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; import { when } from 'lit/directives/when.js'; import { html } from 'lit/static-html.js'; import type { EventName, UIEventHandler } from '../../event/index.js'; import type { BlockService } from '../../extension/index.js'; import type { BlockStdScope } from '../../scope/index.js'; import { PropTypes, requiredProperties } from '../decorators/index.js'; import { blockComponentSymbol, modelContext, serviceContext, } from './consts.js'; import { docContext, stdContext } from './lit-host.js'; import { ShadowlessElement } from './shadowless-element.js'; import type { WidgetComponent } from './widget-component.js'; @requiredProperties({ doc: PropTypes.instanceOf(Blocks), std: PropTypes.object, widgets: PropTypes.recordOf(PropTypes.object), }) export class BlockComponent< Model extends BlockModel = BlockModel, Service extends BlockService = BlockService, WidgetName extends string = string, > extends SignalWatcher(WithDisposable(ShadowlessElement)) { @consume({ context: stdContext }) accessor std!: BlockStdScope; private readonly _selected = computed(() => { const selection = this.std.selection.value.find(selection => { return selection.blockId === this.model?.id; }); if (!selection) { return null; } return selection; }); [blockComponentSymbol] = true; handleEvent = ( name: EventName, handler: UIEventHandler, options?: { global?: boolean; flavour?: boolean } ) => { this._disposables.add( this.host.event.add(name, handler, { flavour: options?.global ? undefined : options?.flavour ? this.model?.flavour : undefined, blockId: options?.global || options?.flavour ? undefined : this.blockId, }) ); }; get blockId() { return this.dataset.blockId as string; } get childBlocks() { const childModels = this.model.children; return childModels .map(child => { return this.std.view.getBlock(child.id); }) .filter((x): x is BlockComponent => !!x); } get flavour(): string { return this.model.flavour; } get host() { return this.std.host; } get isVersionMismatch() { const schema = this.doc.schema.flavourSchemaMap.get(this.model.flavour); if (!schema) { console.warn( `Schema not found for block ${this.model.id}, flavour ${this.model.flavour}` ); return true; } const expectedVersion = schema.version; const actualVersion = this.model.version; if (expectedVersion !== actualVersion) { console.warn( `Version mismatch for block ${this.model.id}, expected ${expectedVersion}, actual ${actualVersion}` ); return true; } return false; } get model() { if (this._model) { return this._model; } const model = this.doc.getBlockById(this.blockId); if (!model) { throw new BlockSuiteError( ErrorCode.MissingViewModelError, `Cannot find block model for id ${this.blockId}` ); } this._model = model; return model; } get parentComponent(): BlockComponent | null { const parent = this.model.parent; if (!parent) return null; return this.std.view.getBlock(parent.id); } get renderChildren() { return this.host.renderChildren.bind(this); } get rootComponent(): BlockComponent | null { const rootId = this.doc.root?.id; if (!rootId) { return null; } const rootComponent = this.host.view.getBlock(rootId); return rootComponent ?? null; } get selected() { return this._selected.value; } get selection() { return this.host.selection; } get service(): Service { if (this._service) { return this._service; } const service = this.std.getService(this.model.flavour) as Service; this._service = service; return service; } get topContenteditableElement(): BlockComponent | null { return this.rootComponent; } get widgetComponents(): Partial> { return Object.keys(this.widgets).reduce( (mapping, key) => ({ ...mapping, [key]: this.host.view.getWidget(key, this.blockId), }), {} ); } private _renderMismatchBlock(content: unknown) { return when( this.isVersionMismatch, () => { const actualVersion = this.model.version; const schema = this.doc.schema.flavourSchemaMap.get(this.model.flavour); const expectedVersion = schema?.version ?? -1; return this.renderVersionMismatch(expectedVersion, actualVersion); }, () => content ); } private _renderViewType(content: unknown) { return choose(this.viewType, [ ['display', () => content], ['hidden', () => nothing], ['bypass', () => this.renderChildren(this.model)], ]); } addRenderer(renderer: (content: unknown) => unknown) { this._renderers.push(renderer); } bindHotKey( keymap: Record, options?: { global?: boolean; flavour?: boolean } ) { const dispose = this.host.event.bindHotkey(keymap, { flavour: options?.global ? undefined : options?.flavour ? this.model.flavour : undefined, blockId: options?.global || options?.flavour ? undefined : this.blockId, }); this._disposables.add(dispose); return dispose; } override connectedCallback() { super.connectedCallback(); this.std.view.setBlock(this); const disposable = this.std.doc.slots.blockUpdated.on(({ type, id }) => { if (id === this.model.id && type === 'delete') { this.std.view.deleteBlock(this); disposable.dispose(); } }); this._disposables.add(disposable); this._disposables.add( this.model.propsUpdated.on(() => { this.requestUpdate(); }) ); this.service?.specSlots.viewConnected.emit({ service: this.service, component: this, }); } override disconnectedCallback() { super.disconnectedCallback(); this.service?.specSlots.viewDisconnected.emit({ service: this.service, component: this, }); } protected override async getUpdateComplete(): Promise { const result = await super.getUpdateComplete(); await Promise.all(this.childBlocks.map(el => el.updateComplete)); return result; } override render() { return this._renderers.reduce( (acc, cur) => cur.call(this, acc), nothing as unknown ); } renderBlock(): unknown { return nothing; } /** * Render a warning message when the block version is mismatched. * @param expectedVersion If the schema is not found, the expected version is -1. * Which means the block is not supported in the current editor. * @param actualVersion The version of the block's crdt data. */ renderVersionMismatch( expectedVersion: number, actualVersion: number ): TemplateResult { return html`

Block Version Mismatched

We can not render this ${this.model.flavour} block because the version is mismatched.

Editor version: ${expectedVersion}

Data version: ${actualVersion}

`; } @provide({ context: modelContext as never }) @state() private accessor _model: Model | null = null; @state() protected accessor _renderers: Array<(content: unknown) => unknown> = [ this.renderBlock, this._renderMismatchBlock, this._renderViewType, ]; @provide({ context: serviceContext as never }) @state() private accessor _service: Service | null = null; @consume({ context: docContext }) accessor doc!: Blocks; @property({ attribute: false }) accessor viewType: BlockViewType = 'display'; @property({ attribute: false, hasChanged(value, oldValue) { if (!value || !oldValue) { return value !== oldValue; } // Is empty object if (!Object.keys(value).length && !Object.keys(oldValue).length) { return false; } return value !== oldValue; }, }) accessor widgets!: Record; }