chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions

View File

@@ -0,0 +1 @@
export { PropTypes, requiredProperties } from './required.js';

View File

@@ -0,0 +1,57 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { Constructor } from '@blocksuite/global/utils';
import type { LitElement } from 'lit';
type ValidatorFunction = (value: unknown) => boolean;
export const PropTypes = {
string: (value: unknown) => typeof value === 'string',
number: (value: unknown) => typeof value === 'number',
boolean: (value: unknown) => typeof value === 'boolean',
object: (value: unknown) => typeof value === 'object',
array: (value: unknown) => Array.isArray(value),
instanceOf: (expectedClass: Constructor) => (value: unknown) =>
value instanceof expectedClass,
arrayOf: (validator: ValidatorFunction) => (value: unknown) =>
Array.isArray(value) && value.every(validator),
recordOf: (validator: ValidatorFunction) => (value: unknown) => {
if (typeof value !== 'object' || value === null) return false;
return Object.values(value).every(validator);
},
};
function validatePropTypes<T extends InstanceType<Constructor>>(
instance: T,
propTypes: Record<string, ValidatorFunction>
) {
for (const [propName, validator] of Object.entries(propTypes)) {
const key = propName as keyof T;
if (instance[key] === undefined) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Property ${propName} is required to ${instance.constructor.name}.`
);
}
if (validator && !validator(instance[key])) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Property ${propName} is invalid to ${instance.constructor.name}.`
);
}
}
}
export function requiredProperties(
propTypes: Record<string, ValidatorFunction>
) {
return function (constructor: Constructor<LitElement>) {
const connectedCallback = constructor.prototype.connectedCallback;
constructor.prototype.connectedCallback = function () {
if (connectedCallback) {
connectedCallback.call(this);
}
validatePropTypes(this, propTypes);
};
};
}

View File

@@ -0,0 +1,329 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { type BlockModel, BlockViewType, Doc } 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(Doc),
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 _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<Model>(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<Record<WidgetName, WidgetComponent>> {
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, [
[BlockViewType.Display, () => content],
[BlockViewType.Hidden, () => nothing],
[BlockViewType.Bypass, () => this.renderChildren(this.model)],
]);
}
addRenderer(renderer: (content: unknown) => unknown) {
this._renderers.push(renderer);
}
bindHotKey(
keymap: Record<string, UIEventHandler>,
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<boolean> {
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`
<dl class="version-mismatch-warning" contenteditable="false">
<dt>
<h4>Block Version Mismatched</h4>
</dt>
<dd>
<p>
We can not render this <var>${this.model.flavour}</var> block
because the version is mismatched.
</p>
<p>Editor version: <var>${expectedVersion}</var></p>
<p>Data version: <var>${actualVersion}</var></p>
</dd>
</dl>
`;
}
@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!: Doc;
@property({ attribute: false })
accessor viewType: BlockViewType = 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<WidgetName, TemplateResult>;
}

View File

@@ -0,0 +1,10 @@
import type { BlockModel } from '@blocksuite/store';
import { createContext } from '@lit/context';
import type { BlockService } from '../../extension/index.js';
export const modelContext = createContext<BlockModel>('model');
export const serviceContext = createContext<BlockService>('service');
export const blockComponentSymbol = Symbol('blockComponent');
export const WIDGET_ID_ATTR = 'data-widget-id';
export const BLOCK_ID_ATTR = 'data-block-id';

View File

@@ -0,0 +1,230 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound } from '@blocksuite/global/utils';
import { nothing } from 'lit';
import type { BlockService } from '../../extension/index.js';
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
import type { GfxBlockElementModel } from '../../gfx/index.js';
import { BlockComponent } from './block-component.js';
export function isGfxBlockComponent(
element: unknown
): element is GfxBlockComponent {
return (element as GfxBlockComponent)?.[GfxElementSymbol] === true;
}
export const GfxElementSymbol = Symbol('GfxElement');
function updateTransform(element: GfxBlockComponent) {
element.style.transformOrigin = '0 0';
element.style.transform = element.getCSSTransform();
}
function handleGfxConnection(instance: GfxBlockComponent) {
instance.style.position = 'absolute';
instance.disposables.add(
instance.gfx.viewport.viewportUpdated.on(() => {
updateTransform(instance);
})
);
instance.disposables.add(
instance.doc.slots.blockUpdated.on(({ type, id }) => {
if (id === instance.model.id && type === 'update') {
updateTransform(instance);
}
})
);
updateTransform(instance);
}
export abstract class GfxBlockComponent<
Model extends GfxBlockElementModel = GfxBlockElementModel,
Service extends BlockService = BlockService,
WidgetName extends string = string,
> extends BlockComponent<Model, Service, WidgetName> {
[GfxElementSymbol] = true;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override connectedCallback(): void {
super.connectedCallback();
handleGfxConnection(this);
}
getCSSTransform() {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
const bound = Bound.deserialize(this.model.xywh);
const scaledX = bound.x * zoom;
const scaledY = bound.y * zoom;
const deltaX = scaledX - bound.x;
const deltaY = scaledY - bound.y;
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
}
getRenderingRect() {
const { xywh$ } = this.model;
if (!xywh$) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
`Error on rendering '${this.model.flavour}': Gfx block's model should have 'xywh' property.`
);
}
const [x, y, w, h] = JSON.parse(xywh$.value);
return { x, y, w, h, zIndex: this.toZIndex() };
}
override renderBlock() {
const { x, y, w, h, zIndex } = this.getRenderingRect();
this.style.left = `${x}px`;
this.style.top = `${y}px`;
this.style.width = `${w}px`;
this.style.height = `${h}px`;
this.style.zIndex = zIndex;
return this.renderGfxBlock();
}
renderGfxBlock(): unknown {
return nothing;
}
renderPageContent(): unknown {
return nothing;
}
override async scheduleUpdate() {
const parent = this.parentElement;
if (this.hasUpdated || !parent || !('scheduleUpdateChildren' in parent)) {
super.scheduleUpdate();
} else {
await (parent.scheduleUpdateChildren as (id: string) => Promise<void>)(
this.model.id
);
super.scheduleUpdate();
}
}
toZIndex(): string {
return this.gfx.layer.getZIndex(this.model).toString() ?? '0';
}
updateZIndex(): void {
this.style.zIndex = this.toZIndex();
}
}
export function toGfxBlockComponent<
Model extends GfxBlockElementModel,
Service extends BlockService,
WidgetName extends string,
B extends typeof BlockComponent<Model, Service, WidgetName>,
>(CustomBlock: B) {
// @ts-expect-error FIXME: ts error
return class extends CustomBlock {
[GfxElementSymbol] = true;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
override connectedCallback(): void {
super.connectedCallback();
handleGfxConnection(this);
}
// eslint-disable-next-line sonarjs/no-identical-functions
getCSSTransform() {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
const bound = Bound.deserialize(this.model.xywh);
const scaledX = bound.x * zoom;
const scaledY = bound.y * zoom;
const deltaX = scaledX - bound.x;
const deltaY = scaledY - bound.y;
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
}
// eslint-disable-next-line sonarjs/no-identical-functions
getRenderingRect(): {
x: number;
y: number;
w: number | string;
h: number | string;
zIndex: string;
} {
const { xywh$ } = this.model;
if (!xywh$) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
`Error on rendering '${this.model.flavour}': Gfx block's model should have 'xywh' property.`
);
}
const [x, y, w, h] = JSON.parse(xywh$.value);
return { x, y, w, h, zIndex: this.toZIndex() };
}
override renderBlock() {
const { x, y, w, h, zIndex } = this.getRenderingRect();
this.style.left = `${x}px`;
this.style.top = `${y}px`;
this.style.width = typeof w === 'number' ? `${w}px` : w;
this.style.height = typeof h === 'number' ? `${h}px` : h;
this.style.zIndex = zIndex;
return this.renderGfxBlock();
}
renderGfxBlock(): unknown {
return this.renderPageContent();
}
renderPageContent() {
return super.renderBlock();
}
// eslint-disable-next-line sonarjs/no-identical-functions
override async scheduleUpdate() {
const parent = this.parentElement;
if (this.hasUpdated || !parent || !('scheduleUpdateChildren' in parent)) {
super.scheduleUpdate();
} else {
await (parent.scheduleUpdateChildren as (id: string) => Promise<void>)(
this.model.id
);
super.scheduleUpdate();
}
}
toZIndex(): string {
return this.gfx.layer.getZIndex(this.model).toString() ?? '0';
}
updateZIndex(): void {
this.style.zIndex = this.toZIndex();
}
} as B & {
new (...args: any[]): GfxBlockComponent;
};
}

View File

@@ -0,0 +1,6 @@
export * from './block-component.js';
export * from './consts.js';
export * from './gfx-block-component.js';
export * from './lit-host.js';
export * from './shadowless-element.js';
export * from './widget-component.js';

View File

@@ -0,0 +1,203 @@
import {
BlockSuiteError,
ErrorCode,
handleError,
} from '@blocksuite/global/exceptions';
import { SignalWatcher, Slot, WithDisposable } from '@blocksuite/global/utils';
import { type BlockModel, BlockViewType, Doc } from '@blocksuite/store';
import { createContext, provide } from '@lit/context';
import { css, LitElement, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { html, type StaticValue, unsafeStatic } from 'lit/static-html.js';
import type { CommandManager } from '../../command/index.js';
import type { UIEventDispatcher } from '../../event/index.js';
import { WidgetViewMapIdentifier } from '../../identifier.js';
import type { RangeManager } from '../../range/index.js';
import type { BlockStdScope } from '../../scope/block-std-scope.js';
import type { SelectionManager } from '../../selection/index.js';
import { PropTypes, requiredProperties } from '../decorators/index.js';
import type { ViewStore } from '../view-store.js';
import { BLOCK_ID_ATTR, WIDGET_ID_ATTR } from './consts.js';
import { ShadowlessElement } from './shadowless-element.js';
export const docContext = createContext<Doc>('doc');
export const stdContext = createContext<BlockStdScope>('std');
@requiredProperties({
doc: PropTypes.instanceOf(Doc),
std: PropTypes.object,
})
export class EditorHost extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
editor-host {
outline: none;
isolation: isolate;
display: block;
height: 100%;
}
`;
private _renderModel = (model: BlockModel): TemplateResult => {
const { flavour } = model;
const block = this.doc.getBlock(model.id);
if (!block || block.blockViewType === BlockViewType.Hidden) {
return html`${nothing}`;
}
const schema = this.doc.schema.flavourSchemaMap.get(flavour);
const view = this.std.getView(flavour);
if (!schema || !view) {
console.warn(`Cannot find render flavour ${flavour}.`);
return html`${nothing}`;
}
const widgetViewMap = this.std.getOptional(
WidgetViewMapIdentifier(flavour)
);
const tag = typeof view === 'function' ? view(model) : view;
const widgets: Record<string, TemplateResult> = widgetViewMap
? Object.entries(widgetViewMap).reduce((mapping, [key, tag]) => {
const template = html`<${tag} ${unsafeStatic(WIDGET_ID_ATTR)}=${key}></${tag}>`;
return {
...mapping,
[key]: template,
};
}, {})
: {};
return html`<${tag}
${unsafeStatic(BLOCK_ID_ATTR)}=${model.id}
.widgets=${widgets}
.viewType=${block.blockViewType}
></${tag}>`;
};
/**
* @deprecated
* Render a block model manually instead of let blocksuite render it.
* If you render the same block model multiple times,
* the event flow and data binding will be broken.
* Only use this method as a last resort.
*/
dangerouslyRenderModel = (model: BlockModel): TemplateResult => {
return this._renderModel(model);
};
renderChildren = (
model: BlockModel,
filter?: (model: BlockModel) => boolean
): TemplateResult => {
return html`${repeat(
model.children.filter(filter ?? (() => true)),
child => child.id,
child => this._renderModel(child)
)}`;
};
readonly slots = {
unmounted: new Slot(),
};
get command(): CommandManager {
return this.std.command;
}
get event(): UIEventDispatcher {
return this.std.event;
}
get range(): RangeManager {
return this.std.range;
}
get selection(): SelectionManager {
return this.std.selection;
}
get view(): ViewStore {
return this.std.view;
}
override connectedCallback() {
super.connectedCallback();
if (!this.doc.root) {
throw new BlockSuiteError(
ErrorCode.NoRootModelError,
'This doc is missing root block. Please initialize the default block structure before connecting the editor to DOM.'
);
}
this.std.mount();
this.tabIndex = 0;
}
override disconnectedCallback() {
super.disconnectedCallback();
this.std.unmount();
this.slots.unmounted.emit();
this.slots.unmounted.dispose();
}
override async getUpdateComplete(): Promise<boolean> {
try {
const result = await super.getUpdateComplete();
const rootModel = this.doc.root;
if (!rootModel) return result;
const view = this.std.getView(rootModel.flavour);
if (!view) return result;
const widgetViewMap = this.std.getOptional(
WidgetViewMapIdentifier(rootModel.flavour)
);
const widgetTags = Object.values(widgetViewMap ?? {});
const elementsTags: StaticValue[] = [
typeof view === 'function' ? view(rootModel) : view,
...widgetTags,
];
await Promise.all(
elementsTags.map(tag => {
const element = this.renderRoot.querySelector(tag._$litStatic$);
if (element instanceof LitElement) {
return element.updateComplete;
}
return null;
})
);
return result;
} catch (e) {
if (e instanceof Error) {
handleError(e);
} else {
console.error(e);
}
return true;
}
}
override render() {
const { root } = this.doc;
if (!root) return nothing;
return this._renderModel(root);
}
@provide({ context: docContext })
@property({ attribute: false })
accessor doc!: Doc;
@provide({ context: stdContext })
@property({ attribute: false })
accessor std!: BlockSuite.Std;
}
declare global {
interface HTMLElementTagNameMap {
'editor-host': EditorHost;
}
}

View File

@@ -0,0 +1,92 @@
import type { Constructor } from '@blocksuite/global/utils';
import type { CSSResultGroup, CSSResultOrNative } from 'lit';
import { CSSResult, LitElement } from 'lit';
export class ShadowlessElement extends LitElement {
// Map of the number of styles injected into a node
// A reference count of the number of ShadowlessElements that are still connected
static connectedCount = new WeakMap<
Constructor, // class
WeakMap<Node, number>
>();
static onDisconnectedMap = new WeakMap<
Constructor, // class
(() => void) | null
>();
// styles registered in ShadowlessElement will be available globally
// even if the element is not being rendered
protected static override finalizeStyles(
styles?: CSSResultGroup
): CSSResultOrNative[] {
const elementStyles = super.finalizeStyles(styles);
// XXX: This breaks component encapsulation and applies styles to the document.
// These styles should be manually scoped.
elementStyles.forEach((s: CSSResultOrNative) => {
if (s instanceof CSSResult && typeof document !== 'undefined') {
const styleRoot = document.head;
const style = document.createElement('style');
style.textContent = s.cssText;
styleRoot.append(style);
}
});
return elementStyles;
}
private getConnectedCount() {
const SE = this.constructor as typeof ShadowlessElement;
return SE.connectedCount.get(SE)?.get(this.getRootNode()) ?? 0;
}
private setConnectedCount(count: number) {
const SE = this.constructor as typeof ShadowlessElement;
if (!SE.connectedCount.has(SE)) {
SE.connectedCount.set(SE, new WeakMap());
}
SE.connectedCount.get(SE)?.set(this.getRootNode(), count);
}
override connectedCallback(): void {
super.connectedCallback();
const parentRoot = this.getRootNode();
const SE = this.constructor as typeof ShadowlessElement;
const insideShadowRoot = parentRoot instanceof ShadowRoot;
const styleInjectedCount = this.getConnectedCount();
if (styleInjectedCount === 0 && insideShadowRoot) {
const elementStyles = SE.elementStyles;
const injectedStyles: HTMLStyleElement[] = [];
elementStyles.forEach((s: CSSResultOrNative) => {
if (s instanceof CSSResult && typeof document !== 'undefined') {
const style = document.createElement('style');
style.textContent = s.cssText;
parentRoot.prepend(style);
injectedStyles.push(style);
}
});
SE.onDisconnectedMap.set(SE, () => {
injectedStyles.forEach(style => style.remove());
});
}
this.setConnectedCount(styleInjectedCount + 1);
}
override createRenderRoot() {
return this;
}
override disconnectedCallback(): void {
super.disconnectedCallback();
const SE = this.constructor as typeof ShadowlessElement;
let styleInjectedCount = this.getConnectedCount();
styleInjectedCount--;
this.setConnectedCount(styleInjectedCount);
if (styleInjectedCount === 0) {
SE.onDisconnectedMap.get(SE)?.();
}
}
}

View File

@@ -0,0 +1,107 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import type { BlockModel, Doc } from '@blocksuite/store';
import { consume } from '@lit/context';
import { LitElement } from 'lit';
import type { EventName, UIEventHandler } from '../../event/index.js';
import type { BlockService } from '../../extension/index.js';
import type { BlockStdScope } from '../../scope/index.js';
import type { BlockComponent } from './block-component.js';
import { modelContext, serviceContext } from './consts.js';
import { docContext, stdContext } from './lit-host.js';
export class WidgetComponent<
Model extends BlockModel = BlockModel,
B extends BlockComponent = BlockComponent,
S extends BlockService = BlockService,
> extends SignalWatcher(WithDisposable(LitElement)) {
handleEvent = (
name: EventName,
handler: UIEventHandler,
options?: { global?: boolean }
) => {
this._disposables.add(
this.host.event.add(name, handler, {
flavour: options?.global ? undefined : this.flavour,
})
);
};
get block() {
return this.std.view.getBlock(this.model.id) as B;
}
get doc() {
return this._doc;
}
get flavour(): string {
return this.model.flavour;
}
get host() {
return this.std.host;
}
get model() {
return this._model;
}
get service() {
return this._service;
}
get std() {
return this._std;
}
get widgetId() {
return this.dataset.widgetId as string;
}
bindHotKey(
keymap: Record<string, UIEventHandler>,
options?: { global: boolean }
) {
this._disposables.add(
this.host.event.bindHotkey(keymap, {
flavour: options?.global ? undefined : this.flavour,
})
);
}
override connectedCallback() {
super.connectedCallback();
this.std.view.setWidget(this);
this.service.specSlots.widgetConnected.emit({
service: this.service,
component: this,
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this.std?.view.deleteWidget(this);
this.service.specSlots.widgetDisconnected.emit({
service: this.service,
component: this,
});
}
override render(): unknown {
return null;
}
@consume({ context: docContext })
private accessor _doc!: Doc;
@consume({ context: modelContext })
private accessor _model!: Model;
@consume({ context: serviceContext as never })
private accessor _service!: S;
@consume({ context: stdContext })
private accessor _std!: BlockStdScope;
}

View File

@@ -0,0 +1,3 @@
export * from './decorators/index.js';
export * from './element/index.js';
export * from './view-store.js';

View File

@@ -0,0 +1,93 @@
import { LifeCycleWatcher } from '../extension/index.js';
import type { BlockComponent, WidgetComponent } from './element/index.js';
export class ViewStore extends LifeCycleWatcher {
static override readonly key = 'viewStore';
private readonly _blockMap = new Map<string, BlockComponent>();
private _fromId = (
blockId: string | undefined | null
): BlockComponent | null => {
const id = blockId ?? this.std.doc.root?.id;
if (!id) {
return null;
}
return this._blockMap.get(id) ?? null;
};
private readonly _widgetMap = new Map<string, WidgetComponent>();
deleteBlock = (node: BlockComponent) => {
this._blockMap.delete(node.id);
};
deleteWidget = (node: WidgetComponent) => {
const id = node.dataset.widgetId as string;
const widgetIndex = `${node.model.id}|${id}`;
this._widgetMap.delete(widgetIndex);
};
getBlock = (id: string): BlockComponent | null => {
return this._blockMap.get(id) ?? null;
};
getWidget = (
widgetName: string,
hostBlockId: string
): WidgetComponent | null => {
const widgetIndex = `${hostBlockId}|${widgetName}`;
return this._widgetMap.get(widgetIndex) ?? null;
};
setBlock = (node: BlockComponent) => {
this._blockMap.set(node.model.id, node);
};
setWidget = (node: WidgetComponent) => {
const id = node.dataset.widgetId as string;
const widgetIndex = `${node.model.id}|${id}`;
this._widgetMap.set(widgetIndex, node);
};
walkThrough = (
fn: (
nodeView: BlockComponent,
index: number,
parent: BlockComponent
) => undefined | null | true,
blockId?: string | undefined | null
) => {
const top = this._fromId(blockId);
if (!top) {
return;
}
const iterate =
(parent: BlockComponent) => (node: BlockComponent, index: number) => {
const result = fn(node, index, parent);
if (result === true) {
return;
}
const children = node.model.children;
children.forEach(child => {
const childNode = this._blockMap.get(child.id);
if (childNode) {
iterate(node)(childNode, children.indexOf(child));
}
});
};
top.model.children.forEach(child => {
const childNode = this._blockMap.get(child.id);
if (childNode) {
iterate(childNode)(childNode, top.model.children.indexOf(child));
}
});
};
override unmounted() {
this._blockMap.clear();
this._widgetMap.clear();
}
}