mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { PropTypes, requiredProperties } from './required.js';
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
10
blocksuite/framework/block-std/src/view/element/consts.ts
Normal file
10
blocksuite/framework/block-std/src/view/element/consts.ts
Normal 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';
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
6
blocksuite/framework/block-std/src/view/element/index.ts
Normal file
6
blocksuite/framework/block-std/src/view/element/index.ts
Normal 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';
|
||||
203
blocksuite/framework/block-std/src/view/element/lit-host.ts
Normal file
203
blocksuite/framework/block-std/src/view/element/lit-host.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
3
blocksuite/framework/block-std/src/view/index.ts
Normal file
3
blocksuite/framework/block-std/src/view/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './decorators/index.js';
|
||||
export * from './element/index.js';
|
||||
export * from './view-store.js';
|
||||
93
blocksuite/framework/block-std/src/view/view-store.ts
Normal file
93
blocksuite/framework/block-std/src/view/view-store.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user