mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 18:46:19 +08:00
refactor(editor): reorg code structure of store package (#9525)
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { type Disposable, Slot } from '@blocksuite/global/utils';
|
||||
import { computed, type Signal, signal } from '@preact/signals-core';
|
||||
|
||||
import type { Text } from '../../reactive/index.js';
|
||||
import type { Blocks } from '../blocks/blocks.js';
|
||||
import type { YBlock } from './types.js';
|
||||
import type { RoleType } from './zod.js';
|
||||
|
||||
type SignaledProps<Props> = Props & {
|
||||
[P in keyof Props & string as `${P}$`]: Signal<Props[P]>;
|
||||
};
|
||||
/**
|
||||
* The MagicProps function is used to append the props to the class.
|
||||
* For example:
|
||||
*
|
||||
* ```ts
|
||||
* class MyBlock extends MagicProps()<{ foo: string }> {}
|
||||
* const myBlock = new MyBlock();
|
||||
* // You'll get type checking for the foo prop
|
||||
* myBlock.foo = 'bar';
|
||||
* ```
|
||||
*/
|
||||
function MagicProps(): {
|
||||
new <Props>(): Props;
|
||||
} {
|
||||
return class {} as never;
|
||||
}
|
||||
|
||||
const modelLabel = Symbol('model_label');
|
||||
|
||||
// @ts-expect-error allow magic props
|
||||
export class BlockModel<
|
||||
Props extends object = object,
|
||||
PropsSignal extends object = SignaledProps<Props>,
|
||||
> extends MagicProps()<PropsSignal> {
|
||||
private readonly _children = signal<string[]>([]);
|
||||
|
||||
/**
|
||||
* @deprecated use doc instead
|
||||
*/
|
||||
page!: Blocks;
|
||||
|
||||
private readonly _childModels = computed(() => {
|
||||
const value: BlockModel[] = [];
|
||||
this._children.value.forEach(id => {
|
||||
const block = this.page.getBlock$(id);
|
||||
if (block) {
|
||||
value.push(block.model);
|
||||
}
|
||||
});
|
||||
return value;
|
||||
});
|
||||
|
||||
private readonly _onCreated: Disposable;
|
||||
|
||||
private readonly _onDeleted: Disposable;
|
||||
|
||||
childMap = computed(() =>
|
||||
this._children.value.reduce((map, id, index) => {
|
||||
map.set(id, index);
|
||||
return map;
|
||||
}, new Map<string, number>())
|
||||
);
|
||||
|
||||
created = new Slot();
|
||||
|
||||
deleted = new Slot();
|
||||
|
||||
flavour!: string;
|
||||
|
||||
id!: string;
|
||||
|
||||
isEmpty = computed(() => {
|
||||
return this._children.value.length === 0;
|
||||
});
|
||||
|
||||
keys!: string[];
|
||||
|
||||
// This is used to avoid https://stackoverflow.com/questions/55886792/infer-typescript-generic-class-type
|
||||
[modelLabel]: Props = 'type_info_label' as never;
|
||||
|
||||
pop!: (prop: keyof Props & string) => void;
|
||||
|
||||
propsUpdated = new Slot<{ key: string }>();
|
||||
|
||||
role!: RoleType;
|
||||
|
||||
stash!: (prop: keyof Props & string) => void;
|
||||
|
||||
// text is optional
|
||||
text?: Text;
|
||||
|
||||
version!: number;
|
||||
|
||||
yBlock!: YBlock;
|
||||
|
||||
get children() {
|
||||
return this._childModels.value;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.page;
|
||||
}
|
||||
|
||||
set doc(doc: Blocks) {
|
||||
this.page = doc;
|
||||
}
|
||||
|
||||
get parent() {
|
||||
return this.doc.getParent(this);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._onCreated = this.created.once(() => {
|
||||
this._children.value = this.yBlock.get('sys:children').toArray();
|
||||
this.yBlock.get('sys:children').observe(event => {
|
||||
this._children.value = event.target.toArray();
|
||||
});
|
||||
this.yBlock.observe(event => {
|
||||
if (event.keysChanged.has('sys:children')) {
|
||||
this._children.value = this.yBlock.get('sys:children').toArray();
|
||||
}
|
||||
});
|
||||
});
|
||||
this._onDeleted = this.deleted.once(() => {
|
||||
this._onCreated.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.created.dispose();
|
||||
this.deleted.dispose();
|
||||
this.propsUpdated.dispose();
|
||||
}
|
||||
|
||||
firstChild(): BlockModel | null {
|
||||
return this.children[0] || null;
|
||||
}
|
||||
|
||||
lastChild(): BlockModel | null {
|
||||
if (!this.children.length) {
|
||||
return this;
|
||||
}
|
||||
return this.children[this.children.length - 1].lastChild();
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
this._onCreated.dispose();
|
||||
this._onDeleted.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Schema } from '../../schema/index.js';
|
||||
import type { Blocks } from '../blocks/blocks.js';
|
||||
import { SyncController } from './sync-controller.js';
|
||||
import type { BlockOptions, YBlock } from './types.js';
|
||||
|
||||
export type BlockViewType = 'bypass' | 'display' | 'hidden';
|
||||
|
||||
export class Block {
|
||||
private readonly _syncController: SyncController;
|
||||
|
||||
blockViewType: BlockViewType = 'display';
|
||||
|
||||
get flavour() {
|
||||
return this._syncController.flavour;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._syncController.id;
|
||||
}
|
||||
|
||||
get model() {
|
||||
return this._syncController.model;
|
||||
}
|
||||
|
||||
get pop() {
|
||||
return this._syncController.pop;
|
||||
}
|
||||
|
||||
get stash() {
|
||||
return this._syncController.stash;
|
||||
}
|
||||
|
||||
get version() {
|
||||
return this._syncController.version;
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly schema: Schema,
|
||||
readonly yBlock: YBlock,
|
||||
readonly doc?: Blocks,
|
||||
readonly options: BlockOptions = {}
|
||||
) {
|
||||
const onChange = !options.onChange
|
||||
? undefined
|
||||
: (key: string, value: unknown) => {
|
||||
options.onChange?.(this, key, value);
|
||||
};
|
||||
this._syncController = new SyncController(schema, yBlock, doc, onChange);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { BlockModel } from './block-model.js';
|
||||
|
||||
type PropsInDraft = 'version' | 'flavour' | 'role' | 'id' | 'keys' | 'text';
|
||||
|
||||
type ModelProps<Model> = Model extends BlockModel<infer U> ? U : never;
|
||||
|
||||
export type DraftModel<Model extends BlockModel = BlockModel> = Pick<
|
||||
Model,
|
||||
PropsInDraft
|
||||
> & {
|
||||
children: DraftModel[];
|
||||
} & ModelProps<Model>;
|
||||
|
||||
export function toDraftModel<Model extends BlockModel = BlockModel>(
|
||||
origin: Model
|
||||
): DraftModel<Model> {
|
||||
const { id, version, flavour, role, keys, text, children } = origin;
|
||||
const props = origin.keys.reduce((acc, key) => {
|
||||
return {
|
||||
...acc,
|
||||
[key]: origin[key as keyof Model],
|
||||
};
|
||||
}, {} as ModelProps<Model>);
|
||||
|
||||
return {
|
||||
id,
|
||||
version,
|
||||
flavour,
|
||||
role,
|
||||
keys,
|
||||
text,
|
||||
children: children.map(toDraftModel),
|
||||
...props,
|
||||
} as DraftModel<Model>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './block.js';
|
||||
export * from './block-model.js';
|
||||
export * from './draft.js';
|
||||
export * from './types.js';
|
||||
export * from './zod.js';
|
||||
@@ -0,0 +1,378 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { createMutex } from 'lib0/mutex.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
Boxed,
|
||||
createYProxy,
|
||||
native2Y,
|
||||
type UnRecord,
|
||||
y2Native,
|
||||
} from '../../reactive/index.js';
|
||||
import type { Schema } from '../../schema/schema.js';
|
||||
import type { Blocks } from '../blocks/blocks.js';
|
||||
import { BlockModel } from './block-model.js';
|
||||
import type { YBlock } from './types.js';
|
||||
import { internalPrimitives } from './zod.js';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* SyncController is responsible for syncing the block data with Yjs.
|
||||
* It creates a proxy model that syncs with Yjs and provides a reactive interface.
|
||||
* It also handles the stashing and popping of props.
|
||||
* It will also provide signals for block props.
|
||||
*
|
||||
*/
|
||||
export class SyncController {
|
||||
private _byPassProxy: boolean = false;
|
||||
|
||||
private readonly _byPassUpdate = (fn: () => void) => {
|
||||
this._byPassProxy = true;
|
||||
fn();
|
||||
this._byPassProxy = false;
|
||||
};
|
||||
|
||||
private readonly _mutex = createMutex();
|
||||
|
||||
private readonly _observeYBlockChanges = () => {
|
||||
this.yBlock.observe(event => {
|
||||
event.keysChanged.forEach(key => {
|
||||
const type = event.changes.keys.get(key);
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
if (type.action === 'update' || type.action === 'add') {
|
||||
const value = this.yBlock.get(key);
|
||||
const keyName = key.replace('prop:', '');
|
||||
const proxy = this._getPropsProxy(keyName, value);
|
||||
this._byPassUpdate(() => {
|
||||
// @ts-expect-error allow magic props
|
||||
this.model[keyName] = proxy;
|
||||
const signalKey = `${keyName}$`;
|
||||
this._mutex(() => {
|
||||
if (signalKey in this.model) {
|
||||
// @ts-expect-error allow magic props
|
||||
this.model[signalKey].value = y2Native(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
this.onChange?.(keyName, value);
|
||||
return;
|
||||
}
|
||||
if (type.action === 'delete') {
|
||||
const keyName = key.replace('prop:', '');
|
||||
this._byPassUpdate(() => {
|
||||
// @ts-expect-error allow magic props
|
||||
delete this.model[keyName];
|
||||
if (`${keyName}$` in this.model) {
|
||||
// @ts-expect-error allow magic props
|
||||
this.model[`${keyName}$`].value = undefined;
|
||||
}
|
||||
});
|
||||
this.onChange?.(keyName, undefined);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _stashed = new Set<string | number>();
|
||||
|
||||
readonly flavour: string;
|
||||
|
||||
readonly id: string;
|
||||
|
||||
readonly model: BlockModel;
|
||||
|
||||
readonly pop = (prop: string) => {
|
||||
if (!this._stashed.has(prop)) return;
|
||||
this._popProp(prop);
|
||||
};
|
||||
|
||||
readonly stash = (prop: string) => {
|
||||
if (this._stashed.has(prop)) return;
|
||||
|
||||
this._stashed.add(prop);
|
||||
this._stashProp(prop);
|
||||
};
|
||||
|
||||
readonly version: number;
|
||||
|
||||
readonly yChildren: Y.Array<string[]>;
|
||||
|
||||
constructor(
|
||||
readonly schema: Schema,
|
||||
readonly yBlock: YBlock,
|
||||
readonly doc?: Blocks,
|
||||
readonly onChange?: (key: string, value: unknown) => void
|
||||
) {
|
||||
const { id, flavour, version, yChildren, props } = this._parseYBlock();
|
||||
|
||||
this.id = id;
|
||||
this.flavour = flavour;
|
||||
this.yChildren = yChildren;
|
||||
this.version = version;
|
||||
|
||||
this.model = this._createModel(props);
|
||||
|
||||
this._observeYBlockChanges();
|
||||
}
|
||||
|
||||
private _createModel(props: UnRecord) {
|
||||
const _mutex = this._mutex;
|
||||
const schema = this.schema.flavourSchemaMap.get(this.flavour);
|
||||
if (!schema) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
`schema for flavour: ${this.flavour} not found`
|
||||
);
|
||||
}
|
||||
|
||||
const model = schema.model.toModel?.() ?? new BlockModel<object>();
|
||||
const signalWithProps = Object.entries(props).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const data = signal(value);
|
||||
const dispose = effect(() => {
|
||||
const value = data.value;
|
||||
if (!this.model) return;
|
||||
_mutex(() => {
|
||||
// @ts-expect-error allow magic props
|
||||
this.model[key] = value;
|
||||
});
|
||||
});
|
||||
model.deleted.once(dispose);
|
||||
return {
|
||||
...acc,
|
||||
[`${key}$`]: data,
|
||||
[key]: value,
|
||||
};
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
Object.assign(model, signalWithProps);
|
||||
|
||||
model.id = this.id;
|
||||
model.version = this.version;
|
||||
model.keys = Object.keys(props);
|
||||
model.flavour = schema.model.flavour;
|
||||
model.role = schema.model.role;
|
||||
model.yBlock = this.yBlock;
|
||||
model.stash = this.stash;
|
||||
model.pop = this.pop;
|
||||
if (this.doc) {
|
||||
model.doc = this.doc;
|
||||
}
|
||||
|
||||
const proxy = new Proxy(model, {
|
||||
has: (target, p) => {
|
||||
return Reflect.has(target, p);
|
||||
},
|
||||
set: (target, p, value, receiver) => {
|
||||
if (
|
||||
!this._byPassProxy &&
|
||||
typeof p === 'string' &&
|
||||
model.keys.includes(p)
|
||||
) {
|
||||
if (this._stashed.has(p)) {
|
||||
setValue(target, p, value);
|
||||
const result = Reflect.set(target, p, value, receiver);
|
||||
this.onChange?.(p, value);
|
||||
return result;
|
||||
}
|
||||
|
||||
const yValue = native2Y(value);
|
||||
this.yBlock.set(`prop:${p}`, yValue);
|
||||
const proxy = this._getPropsProxy(p, yValue);
|
||||
setValue(target, p, value);
|
||||
return Reflect.set(target, p, proxy, receiver);
|
||||
}
|
||||
|
||||
return Reflect.set(target, p, value, receiver);
|
||||
},
|
||||
get: (target, p, receiver) => {
|
||||
return Reflect.get(target, p, receiver);
|
||||
},
|
||||
deleteProperty: (target, p) => {
|
||||
if (
|
||||
!this._byPassProxy &&
|
||||
typeof p === 'string' &&
|
||||
model.keys.includes(p)
|
||||
) {
|
||||
this.yBlock.delete(`prop:${p}`);
|
||||
setValue(target, p, undefined);
|
||||
}
|
||||
|
||||
return Reflect.deleteProperty(target, p);
|
||||
},
|
||||
});
|
||||
|
||||
function setValue(target: BlockModel, p: string, value: unknown) {
|
||||
_mutex(() => {
|
||||
// @ts-expect-error allow magic props
|
||||
target[`${p}$`].value = value;
|
||||
});
|
||||
}
|
||||
return proxy;
|
||||
}
|
||||
|
||||
private _getPropsProxy(name: string, value: unknown) {
|
||||
return createYProxy(value, {
|
||||
onChange: () => {
|
||||
this.onChange?.(name, value);
|
||||
const signalKey = `${name}$`;
|
||||
if (signalKey in this.model) {
|
||||
this._mutex(() => {
|
||||
// @ts-expect-error allow magic props
|
||||
this.model[signalKey].value = this.model[name];
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _parseYBlock() {
|
||||
let id: string | undefined;
|
||||
let flavour: string | undefined;
|
||||
let version: number | undefined;
|
||||
let yChildren: Y.Array<string[]> | undefined;
|
||||
const props: Record<string, unknown> = {};
|
||||
|
||||
this.yBlock.forEach((value, key) => {
|
||||
if (key.startsWith('prop:')) {
|
||||
const keyName = key.replace('prop:', '');
|
||||
props[keyName] = this._getPropsProxy(keyName, value);
|
||||
return;
|
||||
}
|
||||
if (key === 'sys:id' && typeof value === 'string') {
|
||||
id = value;
|
||||
return;
|
||||
}
|
||||
if (key === 'sys:flavour' && typeof value === 'string') {
|
||||
flavour = value;
|
||||
return;
|
||||
}
|
||||
if (key === 'sys:children' && value instanceof Y.Array) {
|
||||
yChildren = value;
|
||||
return;
|
||||
}
|
||||
if (key === 'sys:version' && typeof value === 'number') {
|
||||
version = value;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
'block id is not found when creating model'
|
||||
);
|
||||
}
|
||||
if (!flavour) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
'block flavour is not found when creating model'
|
||||
);
|
||||
}
|
||||
if (!yChildren) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
'block children is not found when creating model'
|
||||
);
|
||||
}
|
||||
|
||||
const schema = this.schema.flavourSchemaMap.get(flavour);
|
||||
if (!schema) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
`schema for flavour: ${flavour} not found`
|
||||
);
|
||||
}
|
||||
const defaultProps = schema.model.props?.(internalPrimitives);
|
||||
|
||||
if (typeof version !== 'number') {
|
||||
// no version found in data, set to schema version
|
||||
version = schema.version;
|
||||
}
|
||||
|
||||
// Set default props if not exists
|
||||
if (defaultProps) {
|
||||
Object.entries(defaultProps).forEach(([key, value]) => {
|
||||
if (key in props) return;
|
||||
|
||||
const yValue = native2Y(value);
|
||||
this.yBlock.set(`prop:${key}`, yValue);
|
||||
props[key] = this._getPropsProxy(key, yValue);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
flavour,
|
||||
version,
|
||||
props,
|
||||
yChildren,
|
||||
};
|
||||
}
|
||||
|
||||
private _popProp(prop: string) {
|
||||
const model = this.model as BlockModel<Record<string, unknown>>;
|
||||
|
||||
const value = model[prop];
|
||||
this._stashed.delete(prop);
|
||||
model[prop] = value;
|
||||
}
|
||||
|
||||
private _stashProp(prop: string) {
|
||||
(this.model as BlockModel<Record<string, unknown>>)[prop] = y2Native(
|
||||
this.yBlock.get(`prop:${prop}`),
|
||||
{
|
||||
transform: (value, origin) => {
|
||||
if (Boxed.is(origin)) {
|
||||
return value;
|
||||
}
|
||||
if (origin instanceof Y.Map) {
|
||||
return new Proxy(value as UnRecord, {
|
||||
get: (target, p, receiver) => {
|
||||
return Reflect.get(target, p, receiver);
|
||||
},
|
||||
set: (target, p, value, receiver) => {
|
||||
const result = Reflect.set(target, p, value, receiver);
|
||||
this.onChange?.(prop, value);
|
||||
return result;
|
||||
},
|
||||
deleteProperty: (target, p) => {
|
||||
const result = Reflect.deleteProperty(target, p);
|
||||
this.onChange?.(prop, undefined);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (origin instanceof Y.Array) {
|
||||
return new Proxy(value as unknown[], {
|
||||
get: (target, p, receiver) => {
|
||||
return Reflect.get(target, p, receiver);
|
||||
},
|
||||
set: (target, p, value, receiver) => {
|
||||
const index = Number(p);
|
||||
if (Number.isNaN(index)) {
|
||||
return Reflect.set(target, p, value, receiver);
|
||||
}
|
||||
const result = Reflect.set(target, p, value, receiver);
|
||||
this.onChange?.(prop, value);
|
||||
return result;
|
||||
},
|
||||
deleteProperty: (target, p) => {
|
||||
const result = Reflect.deleteProperty(target, p);
|
||||
this.onChange?.(p as string, undefined);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import type { BlockModel } from './block-model.js';
|
||||
import type { Block } from './index.js';
|
||||
|
||||
export type YBlock = Y.Map<unknown> & {
|
||||
get(prop: 'sys:id' | 'sys:flavour'): string;
|
||||
get(prop: 'sys:children'): Y.Array<string>;
|
||||
get<T = unknown>(prop: string): T;
|
||||
};
|
||||
|
||||
export type BlockOptions = {
|
||||
onChange?: (block: Block, key: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export type BlockSysProps = {
|
||||
id: string;
|
||||
flavour: string;
|
||||
children?: BlockModel[];
|
||||
};
|
||||
export type BlockProps = BlockSysProps & Record<string, unknown>;
|
||||
@@ -0,0 +1,124 @@
|
||||
import type * as Y from 'yjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Boxed, Text } from '../../reactive/index.js';
|
||||
import type { BaseBlockTransformer } from '../../transformer/base.js';
|
||||
import type { BlockModel } from './block-model.js';
|
||||
|
||||
const FlavourSchema = z.string();
|
||||
const ParentSchema = z.array(z.string()).optional();
|
||||
const ContentSchema = z.array(z.string()).optional();
|
||||
const role = ['root', 'hub', 'content'] as const;
|
||||
const RoleSchema = z.enum(role);
|
||||
|
||||
export type RoleType = (typeof role)[number];
|
||||
|
||||
export interface InternalPrimitives {
|
||||
Text: (input?: Y.Text | string) => Text;
|
||||
Boxed: <T>(input: T) => Boxed<T>;
|
||||
}
|
||||
|
||||
export const internalPrimitives: InternalPrimitives = Object.freeze({
|
||||
Text: (input: Y.Text | string = '') => new Text(input),
|
||||
Boxed: <T>(input: T) => new Boxed(input),
|
||||
});
|
||||
|
||||
export const BlockSchema = z.object({
|
||||
version: z.number(),
|
||||
model: z.object({
|
||||
role: RoleSchema,
|
||||
flavour: FlavourSchema,
|
||||
parent: ParentSchema,
|
||||
children: ContentSchema,
|
||||
props: z
|
||||
.function()
|
||||
.args(z.custom<InternalPrimitives>())
|
||||
.returns(z.record(z.any()))
|
||||
.optional(),
|
||||
toModel: z.function().args().returns(z.custom<BlockModel>()).optional(),
|
||||
}),
|
||||
transformer: z
|
||||
.function()
|
||||
.args()
|
||||
.returns(z.custom<BaseBlockTransformer>())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type BlockSchemaType = z.infer<typeof BlockSchema>;
|
||||
|
||||
export type PropsGetter<Props> = (
|
||||
internalPrimitives: InternalPrimitives
|
||||
) => Props;
|
||||
|
||||
export type SchemaToModel<
|
||||
Schema extends {
|
||||
model: {
|
||||
props: PropsGetter<object>;
|
||||
flavour: string;
|
||||
};
|
||||
},
|
||||
> = BlockModel<ReturnType<Schema['model']['props']>> &
|
||||
ReturnType<Schema['model']['props']> & {
|
||||
flavour: Schema['model']['flavour'];
|
||||
};
|
||||
|
||||
export function defineBlockSchema<
|
||||
Flavour extends string,
|
||||
Role extends RoleType,
|
||||
Props extends object,
|
||||
Metadata extends Readonly<{
|
||||
version: number;
|
||||
role: Role;
|
||||
parent?: string[];
|
||||
children?: string[];
|
||||
}>,
|
||||
Model extends BlockModel<Props>,
|
||||
Transformer extends BaseBlockTransformer<Props>,
|
||||
>(options: {
|
||||
flavour: Flavour;
|
||||
metadata: Metadata;
|
||||
props?: (internalPrimitives: InternalPrimitives) => Props;
|
||||
toModel?: () => Model;
|
||||
transformer?: () => Transformer;
|
||||
}): {
|
||||
version: number;
|
||||
model: {
|
||||
props: PropsGetter<Props>;
|
||||
flavour: Flavour;
|
||||
} & Metadata;
|
||||
transformer?: () => Transformer;
|
||||
};
|
||||
|
||||
export function defineBlockSchema({
|
||||
flavour,
|
||||
props,
|
||||
metadata,
|
||||
toModel,
|
||||
transformer,
|
||||
}: {
|
||||
flavour: string;
|
||||
metadata: {
|
||||
version: number;
|
||||
role: RoleType;
|
||||
parent?: string[];
|
||||
children?: string[];
|
||||
};
|
||||
props?: (internalPrimitives: InternalPrimitives) => Record<string, unknown>;
|
||||
toModel?: () => BlockModel;
|
||||
transformer?: () => BaseBlockTransformer;
|
||||
}): BlockSchemaType {
|
||||
const schema = {
|
||||
version: metadata.version,
|
||||
model: {
|
||||
role: metadata.role,
|
||||
parent: metadata.parent,
|
||||
children: metadata.children,
|
||||
flavour,
|
||||
props,
|
||||
toModel,
|
||||
},
|
||||
transformer,
|
||||
} satisfies z.infer<typeof BlockSchema>;
|
||||
BlockSchema.parse(schema);
|
||||
return schema;
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { type Disposable, Slot } from '@blocksuite/global/utils';
|
||||
import { signal } from '@preact/signals-core';
|
||||
|
||||
import type { Schema } from '../../schema/index.js';
|
||||
import {
|
||||
Block,
|
||||
type BlockModel,
|
||||
type BlockOptions,
|
||||
type BlockProps,
|
||||
type DraftModel,
|
||||
} from '../block/index.js';
|
||||
import type { Doc } from '../doc.js';
|
||||
import { DocCRUD } from './crud.js';
|
||||
import { type Query, runQuery } from './query.js';
|
||||
import { syncBlockProps } from './utils.js';
|
||||
|
||||
type DocOptions = {
|
||||
schema: Schema;
|
||||
blockCollection: Doc;
|
||||
readonly?: boolean;
|
||||
query?: Query;
|
||||
};
|
||||
|
||||
export class Blocks {
|
||||
private readonly _runQuery = (block: Block) => {
|
||||
runQuery(this._query, block);
|
||||
};
|
||||
|
||||
protected readonly _doc: Doc;
|
||||
|
||||
protected readonly _blocks = signal<Record<string, Block>>({});
|
||||
|
||||
protected readonly _crud: DocCRUD;
|
||||
|
||||
protected readonly _disposeBlockUpdated: Disposable;
|
||||
|
||||
protected readonly _query: Query = {
|
||||
match: [],
|
||||
mode: 'loose',
|
||||
};
|
||||
|
||||
protected _readonly?: boolean;
|
||||
|
||||
protected readonly _schema: Schema;
|
||||
|
||||
readonly slots: Doc['slots'] & {
|
||||
/** This is always triggered after `doc.load` is called. */
|
||||
ready: Slot;
|
||||
/**
|
||||
* This fires when the root block is added via API call or has just been initialized from existing ydoc.
|
||||
* useful for internal block UI components to start subscribing following up events.
|
||||
* Note that at this moment, the whole block tree may not be fully initialized yet.
|
||||
*/
|
||||
rootAdded: Slot<string>;
|
||||
rootDeleted: Slot<string>;
|
||||
blockUpdated: Slot<
|
||||
| {
|
||||
type: 'add';
|
||||
id: string;
|
||||
init: boolean;
|
||||
flavour: string;
|
||||
model: BlockModel;
|
||||
}
|
||||
| {
|
||||
type: 'delete';
|
||||
id: string;
|
||||
flavour: string;
|
||||
parent: string;
|
||||
model: BlockModel;
|
||||
}
|
||||
| {
|
||||
type: 'update';
|
||||
id: string;
|
||||
flavour: string;
|
||||
props: { key: string };
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
updateBlock: {
|
||||
<T extends Partial<BlockProps>>(model: BlockModel, props: T): void;
|
||||
(model: BlockModel, callback: () => void): void;
|
||||
} = (
|
||||
model: BlockModel,
|
||||
callBackOrProps: (() => void) | Partial<BlockProps>
|
||||
) => {
|
||||
if (this.readonly) {
|
||||
console.error('cannot modify data in readonly mode');
|
||||
return;
|
||||
}
|
||||
|
||||
const isCallback = typeof callBackOrProps === 'function';
|
||||
|
||||
if (!isCallback) {
|
||||
const parent = this.getParent(model);
|
||||
this.schema.validate(
|
||||
model.flavour,
|
||||
parent?.flavour,
|
||||
callBackOrProps.children?.map(child => child.flavour)
|
||||
);
|
||||
}
|
||||
|
||||
const yBlock = this._yBlocks.get(model.id);
|
||||
if (!yBlock) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
`updating block: ${model.id} not found`
|
||||
);
|
||||
}
|
||||
|
||||
const block = this.getBlock(model.id);
|
||||
if (!block) return;
|
||||
|
||||
this.transact(() => {
|
||||
if (isCallback) {
|
||||
callBackOrProps();
|
||||
this._runQuery(block);
|
||||
return;
|
||||
}
|
||||
|
||||
if (callBackOrProps.children) {
|
||||
this._crud.updateBlockChildren(
|
||||
model.id,
|
||||
callBackOrProps.children.map(child => child.id)
|
||||
);
|
||||
}
|
||||
|
||||
const schema = this.schema.flavourSchemaMap.get(model.flavour);
|
||||
if (!schema) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
`schema for flavour: ${model.flavour} not found`
|
||||
);
|
||||
}
|
||||
syncBlockProps(schema, model, yBlock, callBackOrProps);
|
||||
this._runQuery(block);
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
private get _yBlocks() {
|
||||
return this._doc.yBlocks;
|
||||
}
|
||||
|
||||
get awarenessStore() {
|
||||
return this._doc.awarenessStore;
|
||||
}
|
||||
|
||||
get blobSync() {
|
||||
return this.workspace.blobSync;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this._doc;
|
||||
}
|
||||
|
||||
get blocks() {
|
||||
return this._blocks;
|
||||
}
|
||||
|
||||
get blockSize() {
|
||||
return Object.values(this._blocks.peek()).length;
|
||||
}
|
||||
|
||||
get canRedo() {
|
||||
return this._doc.canRedo;
|
||||
}
|
||||
|
||||
get canUndo() {
|
||||
return this._doc.canUndo;
|
||||
}
|
||||
|
||||
get captureSync() {
|
||||
return this._doc.captureSync.bind(this._doc);
|
||||
}
|
||||
|
||||
get clear() {
|
||||
return this._doc.clear.bind(this._doc);
|
||||
}
|
||||
|
||||
get workspace() {
|
||||
return this._doc.workspace;
|
||||
}
|
||||
|
||||
get history() {
|
||||
return this._doc.history;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._doc.id;
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return Object.values(this._blocks.peek()).length === 0;
|
||||
}
|
||||
|
||||
get loaded() {
|
||||
return this._doc.loaded;
|
||||
}
|
||||
|
||||
get meta() {
|
||||
return this._doc.meta;
|
||||
}
|
||||
|
||||
get readonly() {
|
||||
if (this._doc.readonly) {
|
||||
return true;
|
||||
}
|
||||
return this._readonly === true;
|
||||
}
|
||||
|
||||
set readonly(value: boolean) {
|
||||
this._doc.awarenessStore.setReadonly(this._doc, value);
|
||||
if (this._readonly !== undefined && this._readonly !== value) {
|
||||
this._readonly = value;
|
||||
}
|
||||
}
|
||||
|
||||
get ready() {
|
||||
return this._doc.ready;
|
||||
}
|
||||
|
||||
get redo() {
|
||||
return this._doc.redo.bind(this._doc);
|
||||
}
|
||||
|
||||
get resetHistory() {
|
||||
return this._doc.resetHistory.bind(this._doc);
|
||||
}
|
||||
|
||||
get root() {
|
||||
const rootId = this._crud.root;
|
||||
if (!rootId) return null;
|
||||
return this.getBlock(rootId)?.model ?? null;
|
||||
}
|
||||
|
||||
get rootDoc() {
|
||||
return this._doc.rootDoc;
|
||||
}
|
||||
|
||||
get schema() {
|
||||
return this._schema;
|
||||
}
|
||||
|
||||
get spaceDoc() {
|
||||
return this._doc.spaceDoc;
|
||||
}
|
||||
|
||||
get transact() {
|
||||
return this._doc.transact.bind(this._doc);
|
||||
}
|
||||
|
||||
get undo() {
|
||||
return this._doc.undo.bind(this._doc);
|
||||
}
|
||||
|
||||
get withoutTransact() {
|
||||
return this._doc.withoutTransact.bind(this._doc);
|
||||
}
|
||||
|
||||
constructor({ schema, blockCollection, readonly, query }: DocOptions) {
|
||||
this._doc = blockCollection;
|
||||
|
||||
this.slots = {
|
||||
ready: new Slot(),
|
||||
rootAdded: new Slot(),
|
||||
rootDeleted: new Slot(),
|
||||
blockUpdated: new Slot(),
|
||||
historyUpdated: this._doc.slots.historyUpdated,
|
||||
yBlockUpdated: this._doc.slots.yBlockUpdated,
|
||||
};
|
||||
|
||||
this._crud = new DocCRUD(this._yBlocks, blockCollection.schema);
|
||||
this._schema = schema;
|
||||
this._readonly = readonly;
|
||||
if (query) {
|
||||
this._query = query;
|
||||
}
|
||||
|
||||
this._yBlocks.forEach((_, id) => {
|
||||
if (id in this._blocks.peek()) {
|
||||
return;
|
||||
}
|
||||
this._onBlockAdded(id, true);
|
||||
});
|
||||
|
||||
this._disposeBlockUpdated = this._doc.slots.yBlockUpdated.on(
|
||||
({ type, id }) => {
|
||||
switch (type) {
|
||||
case 'add': {
|
||||
this._onBlockAdded(id);
|
||||
return;
|
||||
}
|
||||
case 'delete': {
|
||||
this._onBlockRemoved(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _getSiblings<T>(
|
||||
block: BlockModel | string,
|
||||
fn: (parent: BlockModel, index: number) => T
|
||||
) {
|
||||
const parent = this.getParent(block);
|
||||
if (!parent) return null;
|
||||
|
||||
const blockModel =
|
||||
typeof block === 'string' ? this.getBlock(block)?.model : block;
|
||||
if (!blockModel) return null;
|
||||
|
||||
const index = parent.children.indexOf(blockModel);
|
||||
if (index === -1) return null;
|
||||
|
||||
return fn(parent, index);
|
||||
}
|
||||
|
||||
private _onBlockAdded(id: string, init = false) {
|
||||
try {
|
||||
if (id in this._blocks.peek()) {
|
||||
return;
|
||||
}
|
||||
const yBlock = this._yBlocks.get(id);
|
||||
if (!yBlock) {
|
||||
console.warn(`Could not find block with id ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const options: BlockOptions = {
|
||||
onChange: (block, key) => {
|
||||
if (key) {
|
||||
block.model.propsUpdated.emit({ key });
|
||||
}
|
||||
|
||||
this.slots.blockUpdated.emit({
|
||||
type: 'update',
|
||||
id,
|
||||
flavour: block.flavour,
|
||||
props: { key },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const block = new Block(this._schema, yBlock, this, options);
|
||||
this._runQuery(block);
|
||||
|
||||
this._blocks.value = {
|
||||
...this._blocks.value,
|
||||
[id]: block,
|
||||
};
|
||||
block.model.created.emit();
|
||||
|
||||
if (block.model.role === 'root') {
|
||||
this.slots.rootAdded.emit(id);
|
||||
}
|
||||
|
||||
this.slots.blockUpdated.emit({
|
||||
type: 'add',
|
||||
id,
|
||||
init,
|
||||
flavour: block.model.flavour,
|
||||
model: block.model,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('An error occurred while adding block:');
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private _onBlockRemoved(id: string) {
|
||||
try {
|
||||
const block = this.getBlock(id);
|
||||
if (!block) return;
|
||||
|
||||
if (block.model.role === 'root') {
|
||||
this.slots.rootDeleted.emit(id);
|
||||
}
|
||||
|
||||
this.slots.blockUpdated.emit({
|
||||
type: 'delete',
|
||||
id,
|
||||
flavour: block.model.flavour,
|
||||
parent: this.getParent(block.model)?.id ?? '',
|
||||
model: block.model,
|
||||
});
|
||||
|
||||
const { [id]: _, ...blocks } = this._blocks.peek();
|
||||
this._blocks.value = blocks;
|
||||
|
||||
block.model.deleted.emit();
|
||||
block.model.dispose();
|
||||
} catch (e) {
|
||||
console.error('An error occurred while removing block:');
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
addBlock<Key extends BlockSuite.Flavour>(
|
||||
flavour: Key,
|
||||
blockProps?: BlockSuite.ModelProps<BlockSuite.BlockModels[Key]>,
|
||||
parent?: BlockModel | string | null,
|
||||
parentIndex?: number
|
||||
): string;
|
||||
|
||||
addBlock(
|
||||
flavour: never,
|
||||
blockProps?: Partial<BlockProps & Omit<BlockProps, 'flavour'>>,
|
||||
parent?: BlockModel | string | null,
|
||||
parentIndex?: number
|
||||
): string;
|
||||
|
||||
addBlock(
|
||||
flavour: string,
|
||||
blockProps: Partial<BlockProps & Omit<BlockProps, 'flavour'>> = {},
|
||||
parent?: BlockModel | string | null,
|
||||
parentIndex?: number
|
||||
): string {
|
||||
if (this.readonly) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
'cannot modify data in readonly mode'
|
||||
);
|
||||
}
|
||||
|
||||
const id = blockProps.id ?? this._doc.workspace.idGenerator();
|
||||
|
||||
this.transact(() => {
|
||||
this._crud.addBlock(
|
||||
id,
|
||||
flavour,
|
||||
{ ...blockProps },
|
||||
typeof parent === 'string' ? parent : parent?.id,
|
||||
parentIndex
|
||||
);
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
addBlocks(
|
||||
blocks: Array<{
|
||||
flavour: string;
|
||||
blockProps?: Partial<BlockProps & Omit<BlockProps, 'flavour' | 'id'>>;
|
||||
}>,
|
||||
parent?: BlockModel | string | null,
|
||||
parentIndex?: number
|
||||
): string[] {
|
||||
const ids: string[] = [];
|
||||
blocks.forEach(block => {
|
||||
const id = this.addBlock(
|
||||
block.flavour as never,
|
||||
block.blockProps ?? {},
|
||||
parent,
|
||||
parentIndex
|
||||
);
|
||||
ids.push(id);
|
||||
typeof parentIndex === 'number' && parentIndex++;
|
||||
});
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
addSiblingBlocks(
|
||||
targetModel: BlockModel,
|
||||
props: Array<Partial<BlockProps>>,
|
||||
place: 'after' | 'before' = 'after'
|
||||
): string[] {
|
||||
if (!props.length) return [];
|
||||
const parent = this.getParent(targetModel);
|
||||
if (!parent) return [];
|
||||
|
||||
const targetIndex =
|
||||
parent.children.findIndex(({ id }) => id === targetModel.id) ?? 0;
|
||||
const insertIndex = place === 'before' ? targetIndex : targetIndex + 1;
|
||||
|
||||
if (props.length <= 1) {
|
||||
if (!props[0]?.flavour) return [];
|
||||
const { flavour, ...blockProps } = props[0];
|
||||
const id = this.addBlock(
|
||||
flavour as never,
|
||||
blockProps,
|
||||
parent.id,
|
||||
insertIndex
|
||||
);
|
||||
return [id];
|
||||
}
|
||||
|
||||
const blocks: Array<{
|
||||
flavour: string;
|
||||
blockProps: Partial<BlockProps>;
|
||||
}> = [];
|
||||
props.forEach(prop => {
|
||||
const { flavour, ...blockProps } = prop;
|
||||
if (!flavour) return;
|
||||
blocks.push({ flavour, blockProps });
|
||||
});
|
||||
return this.addBlocks(blocks, parent.id, insertIndex);
|
||||
}
|
||||
|
||||
deleteBlock(
|
||||
model: DraftModel,
|
||||
options: {
|
||||
bringChildrenTo?: BlockModel;
|
||||
deleteChildren?: boolean;
|
||||
} = {
|
||||
deleteChildren: true,
|
||||
}
|
||||
) {
|
||||
if (this.readonly) {
|
||||
console.error('cannot modify data in readonly mode');
|
||||
return;
|
||||
}
|
||||
|
||||
const opts = (
|
||||
options && options.bringChildrenTo
|
||||
? {
|
||||
...options,
|
||||
bringChildrenTo: options.bringChildrenTo.id,
|
||||
}
|
||||
: options
|
||||
) as {
|
||||
bringChildrenTo?: string;
|
||||
deleteChildren?: boolean;
|
||||
};
|
||||
|
||||
this.transact(() => {
|
||||
this._crud.deleteBlock(model.id, opts);
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._disposeBlockUpdated.dispose();
|
||||
this.slots.ready.dispose();
|
||||
this.slots.blockUpdated.dispose();
|
||||
this.slots.rootAdded.dispose();
|
||||
this.slots.rootDeleted.dispose();
|
||||
}
|
||||
|
||||
getBlock(id: string): Block | undefined {
|
||||
return this._blocks.peek()[id];
|
||||
}
|
||||
|
||||
getBlock$(id: string): Block | undefined {
|
||||
return this._blocks.value[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* Use `getBlocksByFlavour` instead.
|
||||
*/
|
||||
getBlockByFlavour(blockFlavour: string | string[]) {
|
||||
return this.getBlocksByFlavour(blockFlavour).map(x => x.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* Use `getBlock` instead.
|
||||
*/
|
||||
getBlockById<Model extends BlockModel = BlockModel>(
|
||||
id: string
|
||||
): Model | null {
|
||||
return (this.getBlock(id)?.model ?? null) as Model | null;
|
||||
}
|
||||
|
||||
getBlocks() {
|
||||
return Object.values(this._blocks.peek()).map(block => block.model);
|
||||
}
|
||||
|
||||
getBlocksByFlavour(blockFlavour: string | string[]) {
|
||||
const flavours =
|
||||
typeof blockFlavour === 'string' ? [blockFlavour] : blockFlavour;
|
||||
|
||||
return Object.values(this._blocks.peek()).filter(({ flavour }) =>
|
||||
flavours.includes(flavour)
|
||||
);
|
||||
}
|
||||
|
||||
getNext(block: BlockModel | string) {
|
||||
return this._getSiblings(
|
||||
block,
|
||||
(parent, index) => parent.children[index + 1] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
getNexts(block: BlockModel | string) {
|
||||
return (
|
||||
this._getSiblings(block, (parent, index) =>
|
||||
parent.children.slice(index + 1)
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
getParent(target: BlockModel | string): BlockModel | null {
|
||||
const targetId = typeof target === 'string' ? target : target.id;
|
||||
const parentId = this._crud.getParent(targetId);
|
||||
if (!parentId) return null;
|
||||
|
||||
const parent = this._blocks.peek()[parentId];
|
||||
if (!parent) return null;
|
||||
|
||||
return parent.model;
|
||||
}
|
||||
|
||||
getPrev(block: BlockModel | string) {
|
||||
return this._getSiblings(
|
||||
block,
|
||||
(parent, index) => parent.children[index - 1] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
getPrevs(block: BlockModel | string) {
|
||||
return (
|
||||
this._getSiblings(block, (parent, index) =>
|
||||
parent.children.slice(0, index)
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
getSchemaByFlavour(flavour: BlockSuite.Flavour) {
|
||||
return this._schema.flavourSchemaMap.get(flavour);
|
||||
}
|
||||
|
||||
hasBlock(id: string) {
|
||||
return id in this._blocks.peek();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* Use `hasBlock` instead.
|
||||
*/
|
||||
hasBlockById(id: string) {
|
||||
return this.hasBlock(id);
|
||||
}
|
||||
|
||||
load(initFn?: () => void) {
|
||||
this._doc.load(initFn);
|
||||
this.slots.ready.emit();
|
||||
return this;
|
||||
}
|
||||
|
||||
moveBlocks(
|
||||
blocksToMove: BlockModel[],
|
||||
newParent: BlockModel,
|
||||
targetSibling: BlockModel | null = null,
|
||||
shouldInsertBeforeSibling = true
|
||||
) {
|
||||
if (this.readonly) {
|
||||
console.error('Cannot modify data in read-only mode');
|
||||
return;
|
||||
}
|
||||
|
||||
this.transact(() => {
|
||||
this._crud.moveBlocks(
|
||||
blocksToMove.map(model => model.id),
|
||||
newParent.id,
|
||||
targetSibling?.id ?? null,
|
||||
shouldInsertBeforeSibling
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { native2Y } from '../../reactive/index.js';
|
||||
import type { Schema } from '../../schema/index.js';
|
||||
import type { BlockModel } from '../block/block-model.js';
|
||||
import type { YBlock } from '../block/types.js';
|
||||
import { internalPrimitives } from '../block/zod.js';
|
||||
|
||||
export class DocCRUD {
|
||||
get root(): string | null {
|
||||
let rootId: string | null = null;
|
||||
this._yBlocks.forEach(yBlock => {
|
||||
const flavour = yBlock.get('sys:flavour') as string;
|
||||
const schema = this._schema.flavourSchemaMap.get(flavour);
|
||||
if (!schema) return;
|
||||
|
||||
if (schema.model.role === 'root') {
|
||||
rootId = yBlock.get('sys:id') as string;
|
||||
}
|
||||
});
|
||||
return rootId;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly _yBlocks: Y.Map<YBlock>,
|
||||
private readonly _schema: Schema
|
||||
) {}
|
||||
|
||||
private _getSiblings<T>(
|
||||
id: string,
|
||||
fn: (index: number, parent: YBlock) => T
|
||||
) {
|
||||
const parentId = this.getParent(id);
|
||||
if (!parentId) return null;
|
||||
const parent = this._yBlocks.get(parentId);
|
||||
if (!parent) return null;
|
||||
|
||||
const children = parent.get('sys:children');
|
||||
const index = children.toArray().indexOf(id);
|
||||
if (index === -1) return null;
|
||||
|
||||
return fn(index, parent);
|
||||
}
|
||||
|
||||
addBlock(
|
||||
id: string,
|
||||
flavour: string,
|
||||
initialProps: Record<string, unknown> = {},
|
||||
parent?: string | null,
|
||||
parentIndex?: number
|
||||
) {
|
||||
const schema = this._schema.flavourSchemaMap.get(flavour);
|
||||
if (!schema) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
`schema for flavour: ${flavour} not found`
|
||||
);
|
||||
}
|
||||
|
||||
const parentFlavour = parent
|
||||
? this._yBlocks.get(parent)?.get('sys:flavour')
|
||||
: undefined;
|
||||
|
||||
this._schema.validate(flavour, parentFlavour as string);
|
||||
|
||||
const hasBlock = this._yBlocks.has(id);
|
||||
|
||||
if (hasBlock) {
|
||||
const yBlock = this._yBlocks.get(id);
|
||||
const existedParent = this.getParent(id);
|
||||
if (yBlock && existedParent) {
|
||||
const yParent = this._yBlocks.get(existedParent) as YBlock;
|
||||
const yParentChildren = yParent.get('sys:children') as Y.Array<string>;
|
||||
const index = yParentChildren.toArray().indexOf(id);
|
||||
yParentChildren.delete(index, 1);
|
||||
if (
|
||||
parentIndex != null &&
|
||||
index != null &&
|
||||
existedParent === parent &&
|
||||
index < parentIndex
|
||||
) {
|
||||
parentIndex--;
|
||||
}
|
||||
const props = {
|
||||
...initialProps,
|
||||
};
|
||||
delete props.id;
|
||||
delete props.flavour;
|
||||
delete props.children;
|
||||
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (value === undefined) return;
|
||||
|
||||
yBlock.set(`prop:${key}`, native2Y(value));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const yBlock = new Y.Map() as YBlock;
|
||||
this._yBlocks.set(id, yBlock);
|
||||
|
||||
const version = schema.version;
|
||||
const children = (
|
||||
initialProps.children as undefined | (string | BlockModel)[]
|
||||
)?.map(child => (typeof child === 'string' ? child : child.id));
|
||||
|
||||
yBlock.set('sys:id', id);
|
||||
yBlock.set('sys:flavour', flavour);
|
||||
yBlock.set('sys:version', version);
|
||||
yBlock.set('sys:children', Y.Array.from(children ?? []));
|
||||
|
||||
const defaultProps = schema.model.props?.(internalPrimitives) ?? {};
|
||||
const props = {
|
||||
...defaultProps,
|
||||
...initialProps,
|
||||
};
|
||||
|
||||
delete props.id;
|
||||
delete props.flavour;
|
||||
delete props.children;
|
||||
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (value === undefined) return;
|
||||
|
||||
yBlock.set(`prop:${key}`, native2Y(value));
|
||||
});
|
||||
}
|
||||
|
||||
const parentId =
|
||||
parent ?? (schema.model.role === 'root' ? null : this.root);
|
||||
|
||||
if (!parentId) return;
|
||||
|
||||
const yParent = this._yBlocks.get(parentId);
|
||||
if (!yParent) return;
|
||||
|
||||
const yParentChildren = yParent.get('sys:children') as Y.Array<string>;
|
||||
const index =
|
||||
parentIndex != null
|
||||
? parentIndex > yParentChildren.length
|
||||
? yParentChildren.length
|
||||
: parentIndex
|
||||
: yParentChildren.length;
|
||||
yParentChildren.insert(index, [id]);
|
||||
}
|
||||
|
||||
deleteBlock(
|
||||
id: string,
|
||||
options: {
|
||||
bringChildrenTo?: string;
|
||||
deleteChildren?: boolean;
|
||||
} = {
|
||||
deleteChildren: true,
|
||||
}
|
||||
) {
|
||||
const { bringChildrenTo, deleteChildren } = options;
|
||||
if (bringChildrenTo && deleteChildren) {
|
||||
console.error(
|
||||
'Cannot bring children to another block and delete them at the same time'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const yModel = this._yBlocks.get(id);
|
||||
if (!yModel) return;
|
||||
|
||||
const yModelChildren = yModel.get('sys:children') as Y.Array<string>;
|
||||
|
||||
const parent = this.getParent(id);
|
||||
|
||||
if (!parent) return;
|
||||
const yParent = this._yBlocks.get(parent) as YBlock;
|
||||
const yParentChildren = yParent.get('sys:children') as Y.Array<string>;
|
||||
const modelIndex = yParentChildren.toArray().indexOf(id);
|
||||
|
||||
if (modelIndex > -1) {
|
||||
yParentChildren.delete(modelIndex, 1);
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
if (bringChildrenTo) {
|
||||
const bringChildrenToModel = () => {
|
||||
if (!bringChildrenTo) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
'bringChildrenTo is not provided when deleting block'
|
||||
);
|
||||
}
|
||||
const model = this._yBlocks.get(bringChildrenTo);
|
||||
if (!model) return;
|
||||
const bringFlavour = model.get('sys:flavour');
|
||||
|
||||
yModelChildren.forEach(child => {
|
||||
const childModel = this._yBlocks.get(child);
|
||||
if (!childModel) return;
|
||||
this._schema.validate(
|
||||
childModel.get('sys:flavour') as string,
|
||||
bringFlavour as string
|
||||
);
|
||||
});
|
||||
|
||||
if (bringChildrenTo === parent) {
|
||||
// When bring children to parent, insert children to the original position of model
|
||||
yParentChildren.insert(modelIndex, yModelChildren.toArray());
|
||||
return;
|
||||
}
|
||||
|
||||
const yBringChildrenTo = this._yBlocks.get(bringChildrenTo);
|
||||
if (!yBringChildrenTo) return;
|
||||
|
||||
const yBringChildrenToChildren = yBringChildrenTo.get(
|
||||
'sys:children'
|
||||
) as Y.Array<string>;
|
||||
yBringChildrenToChildren.push(yModelChildren.toArray());
|
||||
};
|
||||
|
||||
bringChildrenToModel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteChildren) {
|
||||
// delete children recursively
|
||||
const deleteById = (id: string) => {
|
||||
const yBlock = this._yBlocks.get(id) as YBlock;
|
||||
|
||||
const yChildren = yBlock.get('sys:children') as Y.Array<string>;
|
||||
yChildren.forEach(id => deleteById(id));
|
||||
|
||||
this._yBlocks.delete(id);
|
||||
};
|
||||
|
||||
yModelChildren.forEach(id => deleteById(id));
|
||||
}
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
this._yBlocks.delete(id);
|
||||
}
|
||||
|
||||
getNext(id: string) {
|
||||
return this._getSiblings(
|
||||
id,
|
||||
(index, parent) =>
|
||||
parent
|
||||
.get('sys:children')
|
||||
.toArray()
|
||||
.at(index + 1) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
getParent(targetId: string): string | null {
|
||||
const root = this.root;
|
||||
if (!root || root === targetId) return null;
|
||||
|
||||
const findParent = (parentId: string): string | null => {
|
||||
const parentYBlock = this._yBlocks.get(parentId);
|
||||
if (!parentYBlock) return null;
|
||||
|
||||
const children = parentYBlock.get('sys:children');
|
||||
|
||||
for (const childId of children.toArray()) {
|
||||
if (childId === targetId) return parentId;
|
||||
|
||||
const parent = findParent(childId);
|
||||
if (parent != null) return parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return findParent(root);
|
||||
}
|
||||
|
||||
getPrev(id: string) {
|
||||
return this._getSiblings(
|
||||
id,
|
||||
(index, parent) =>
|
||||
parent
|
||||
.get('sys:children')
|
||||
.toArray()
|
||||
.at(index - 1) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
moveBlocks(
|
||||
blocksToMove: string[],
|
||||
newParent: string,
|
||||
targetSibling: string | null = null,
|
||||
shouldInsertBeforeSibling = true
|
||||
) {
|
||||
// A map to store parent block and their respective child blocks
|
||||
const childBlocksPerParent = new Map<string, string[]>();
|
||||
|
||||
const parentBlock = this._yBlocks.get(newParent);
|
||||
if (!parentBlock) return;
|
||||
|
||||
const parentFlavour = parentBlock.get('sys:flavour');
|
||||
|
||||
blocksToMove.forEach(blockId => {
|
||||
const parent = this.getParent(blockId);
|
||||
if (!parent) return;
|
||||
|
||||
const block = this._yBlocks.get(blockId);
|
||||
if (!block) return;
|
||||
|
||||
this._schema.validate(
|
||||
block.get('sys:flavour') as string,
|
||||
parentFlavour as string
|
||||
);
|
||||
|
||||
const children = childBlocksPerParent.get(parent);
|
||||
if (!children) {
|
||||
childBlocksPerParent.set(parent, [blockId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const last = children[children.length - 1];
|
||||
if (this.getNext(last) !== blockId) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
'The blocks to move are not contiguous under their parent'
|
||||
);
|
||||
}
|
||||
|
||||
children.push(blockId);
|
||||
});
|
||||
|
||||
let insertIndex = 0;
|
||||
Array.from(childBlocksPerParent.entries()).forEach(
|
||||
([parentBlock, blocksToMove], index) => {
|
||||
const targetParentBlock = this._yBlocks.get(newParent);
|
||||
if (!targetParentBlock) return;
|
||||
const targetParentChildren = targetParentBlock.get('sys:children');
|
||||
const sourceParentBlock = this._yBlocks.get(parentBlock);
|
||||
if (!sourceParentBlock) return;
|
||||
const sourceParentChildren = sourceParentBlock.get('sys:children');
|
||||
|
||||
// Get the IDs of blocks to move
|
||||
// Remove the blocks from their current parent
|
||||
const startIndex = sourceParentChildren
|
||||
.toArray()
|
||||
.findIndex(id => id === blocksToMove[0]);
|
||||
sourceParentChildren.delete(startIndex, blocksToMove.length);
|
||||
|
||||
const updateInsertIndex = () => {
|
||||
const first = index === 0;
|
||||
if (!first) {
|
||||
insertIndex++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetSibling) {
|
||||
insertIndex = targetParentChildren.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = targetParentChildren
|
||||
.toArray()
|
||||
.findIndex(id => id === targetSibling);
|
||||
if (targetIndex === -1) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ModelCRUDError,
|
||||
'Target sibling not found'
|
||||
);
|
||||
}
|
||||
insertIndex = shouldInsertBeforeSibling
|
||||
? targetIndex
|
||||
: targetIndex + 1;
|
||||
};
|
||||
|
||||
updateInsertIndex();
|
||||
|
||||
targetParentChildren.insert(insertIndex, blocksToMove);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateBlockChildren(id: string, children: string[]) {
|
||||
const yBlock = this._yBlocks.get(id);
|
||||
if (!yBlock) return;
|
||||
|
||||
const yChildrenArray = yBlock.get('sys:children') as Y.Array<string>;
|
||||
yChildrenArray.delete(0, yChildrenArray.length);
|
||||
yChildrenArray.push(children);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './blocks.js';
|
||||
export * from './query.js';
|
||||
@@ -0,0 +1,82 @@
|
||||
import isMatch from 'lodash.ismatch';
|
||||
|
||||
import type { Block, BlockModel, BlockViewType } from '../block/index.js';
|
||||
|
||||
export type QueryMatch = {
|
||||
id?: string;
|
||||
flavour?: string;
|
||||
props?: Record<string, unknown>;
|
||||
viewType: BlockViewType;
|
||||
};
|
||||
|
||||
/**
|
||||
* - `strict` means that only blocks that match the query will be included.
|
||||
* - `loose` means that all blocks will be included first, and then the blocks will be run through the query.
|
||||
* - `include` means that only blocks and their ancestors that match the query will be included.
|
||||
*/
|
||||
type QueryMode = 'strict' | 'loose' | 'include';
|
||||
|
||||
export type Query = {
|
||||
match: QueryMatch[];
|
||||
mode: QueryMode;
|
||||
};
|
||||
|
||||
export function runQuery(query: Query, block: Block) {
|
||||
const blockViewType = getBlockViewType(query, block);
|
||||
block.blockViewType = blockViewType;
|
||||
|
||||
if (blockViewType !== 'hidden') {
|
||||
const queryMode = query.mode;
|
||||
setAncestorsToDisplayIfHidden(queryMode, block);
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockViewType(query: Query, block: Block): BlockViewType {
|
||||
const flavour = block.model.flavour;
|
||||
const id = block.model.id;
|
||||
const queryMode = query.mode;
|
||||
const props = block.model.keys.reduce(
|
||||
(acc, key) => {
|
||||
return {
|
||||
...acc,
|
||||
[key]: block.model[key as keyof BlockModel],
|
||||
};
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
let blockViewType: BlockViewType =
|
||||
queryMode === 'loose' ? 'display' : 'hidden';
|
||||
|
||||
query.match.some(queryObject => {
|
||||
const {
|
||||
id: queryId,
|
||||
flavour: queryFlavour,
|
||||
props: queryProps,
|
||||
viewType,
|
||||
} = queryObject;
|
||||
const matchQueryId = queryId == null ? true : queryId === id;
|
||||
const matchQueryFlavour =
|
||||
queryFlavour == null ? true : queryFlavour === flavour;
|
||||
const matchQueryProps =
|
||||
queryProps == null ? true : isMatch(props, queryProps);
|
||||
if (matchQueryId && matchQueryFlavour && matchQueryProps) {
|
||||
blockViewType = viewType;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return blockViewType;
|
||||
}
|
||||
|
||||
function setAncestorsToDisplayIfHidden(mode: QueryMode, block: Block) {
|
||||
const doc = block.model.doc;
|
||||
let parent = doc.getParent(block.model);
|
||||
while (parent) {
|
||||
const parentBlock = doc.getBlock(parent.id);
|
||||
if (parentBlock && parentBlock.blockViewType === 'hidden') {
|
||||
parentBlock.blockViewType = mode === 'include' ? 'display' : 'bypass';
|
||||
}
|
||||
parent = doc.getParent(parent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { SYS_KEYS } from '../../consts.js';
|
||||
import { native2Y } from '../../reactive/index.js';
|
||||
import type { BlockModel } from '../block/block-model.js';
|
||||
import type { BlockProps, YBlock } from '../block/types.js';
|
||||
import type { BlockSchema } from '../block/zod.js';
|
||||
import { internalPrimitives } from '../block/zod.js';
|
||||
|
||||
export function syncBlockProps(
|
||||
schema: z.infer<typeof BlockSchema>,
|
||||
model: BlockModel,
|
||||
yBlock: YBlock,
|
||||
props: Partial<BlockProps>
|
||||
) {
|
||||
const defaultProps = schema.model.props?.(internalPrimitives) ?? {};
|
||||
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (SYS_KEYS.has(key)) return;
|
||||
if (value === undefined) return;
|
||||
|
||||
// @ts-expect-error allow props
|
||||
model[key] = value;
|
||||
});
|
||||
|
||||
// set default value
|
||||
Object.entries(defaultProps).forEach(([key, value]) => {
|
||||
const notExists =
|
||||
!yBlock.has(`prop:${key}`) || yBlock.get(`prop:${key}`) === undefined;
|
||||
if (!notExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error allow props
|
||||
model[key] = native2Y(value);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Slot } from '@blocksuite/global/utils';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import type { Schema } from '../schema/schema.js';
|
||||
import type { AwarenessStore } from '../yjs/awareness.js';
|
||||
import type { YBlock } from './block/types.js';
|
||||
import type { Blocks } from './blocks/blocks.js';
|
||||
import type { Query } from './blocks/query.js';
|
||||
import type { Workspace } from './workspace.js';
|
||||
import type { DocMeta } from './workspace-meta.js';
|
||||
|
||||
export type GetBlocksOptions = {
|
||||
query?: Query;
|
||||
readonly?: boolean;
|
||||
};
|
||||
export type CreateBlocksOptions = GetBlocksOptions & {
|
||||
id?: string;
|
||||
};
|
||||
export type YBlocks = Y.Map<YBlock>;
|
||||
|
||||
export interface Doc {
|
||||
readonly id: string;
|
||||
get meta(): DocMeta | undefined;
|
||||
get schema(): Schema;
|
||||
|
||||
remove(): void;
|
||||
load(initFn?: () => void): void;
|
||||
get ready(): boolean;
|
||||
dispose(): void;
|
||||
|
||||
slots: {
|
||||
historyUpdated: Slot;
|
||||
yBlockUpdated: Slot<
|
||||
| {
|
||||
type: 'add';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'delete';
|
||||
id: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
get history(): Y.UndoManager;
|
||||
get canRedo(): boolean;
|
||||
get canUndo(): boolean;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
resetHistory(): void;
|
||||
transact(fn: () => void, shouldTransact?: boolean): void;
|
||||
withoutTransact(fn: () => void): void;
|
||||
|
||||
captureSync(): void;
|
||||
clear(): void;
|
||||
getBlocks(options?: GetBlocksOptions): Blocks;
|
||||
clearQuery(query: Query, readonly?: boolean): void;
|
||||
|
||||
get loaded(): boolean;
|
||||
get readonly(): boolean;
|
||||
get awarenessStore(): AwarenessStore;
|
||||
|
||||
get workspace(): Workspace;
|
||||
|
||||
get rootDoc(): Y.Doc;
|
||||
get spaceDoc(): Y.Doc;
|
||||
get yBlocks(): Y.Map<YBlock>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { BlockModel } from './block/block-model.js';
|
||||
|
||||
export * from './block/index.js';
|
||||
export * from './blocks/index.js';
|
||||
export * from './doc.js';
|
||||
export * from './workspace.js';
|
||||
export * from './workspace-meta.js';
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface BlockModels {}
|
||||
|
||||
type Flavour = string & keyof BlockModels;
|
||||
|
||||
type ModelProps<Model> = Partial<
|
||||
Model extends BlockModel<infer U> ? U : never
|
||||
>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Slot } from '@blocksuite/global/utils';
|
||||
|
||||
import type { Workspace } from './workspace.js';
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
value: string;
|
||||
color: string;
|
||||
};
|
||||
export type DocsPropertiesMeta = {
|
||||
tags?: {
|
||||
options: Tag[];
|
||||
};
|
||||
};
|
||||
export interface DocMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
createDate: number;
|
||||
updatedDate?: number;
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceMeta {
|
||||
get docMetas(): DocMeta[];
|
||||
|
||||
addDocMeta(props: DocMeta, index?: number): void;
|
||||
getDocMeta(id: string): DocMeta | undefined;
|
||||
setDocMeta(id: string, props: Partial<DocMeta>): void;
|
||||
removeDocMeta(id: string): void;
|
||||
|
||||
get properties(): DocsPropertiesMeta;
|
||||
setProperties(meta: DocsPropertiesMeta): void;
|
||||
|
||||
get avatar(): string | undefined;
|
||||
setAvatar(avatar: string): void;
|
||||
|
||||
get name(): string | undefined;
|
||||
setName(name: string): void;
|
||||
|
||||
hasVersion: boolean;
|
||||
writeVersion(workspace: Workspace): void;
|
||||
get docs(): unknown[] | undefined;
|
||||
initialize(): void;
|
||||
|
||||
commonFieldsUpdated: Slot;
|
||||
docMetaAdded: Slot<string>;
|
||||
docMetaRemoved: Slot<string>;
|
||||
docMetaUpdated: Slot;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Slot } from '@blocksuite/global/utils';
|
||||
import type { BlobEngine, DocEngine } from '@blocksuite/sync';
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
import type { Schema } from '../schema/schema.js';
|
||||
import type { IdGenerator } from '../utils/id-generator.js';
|
||||
import type { AwarenessStore } from '../yjs/awareness.js';
|
||||
import type { Blocks } from './blocks/blocks.js';
|
||||
import type { CreateBlocksOptions, Doc, GetBlocksOptions } from './doc.js';
|
||||
import type { WorkspaceMeta } from './workspace-meta.js';
|
||||
|
||||
export interface Workspace {
|
||||
readonly id: string;
|
||||
readonly meta: WorkspaceMeta;
|
||||
readonly idGenerator: IdGenerator;
|
||||
readonly docSync: DocEngine;
|
||||
readonly blobSync: BlobEngine;
|
||||
readonly awarenessStore: AwarenessStore;
|
||||
|
||||
get schema(): Schema;
|
||||
get doc(): Y.Doc;
|
||||
get docs(): Map<string, Doc>;
|
||||
|
||||
slots: {
|
||||
docListUpdated: Slot;
|
||||
docCreated: Slot<string>;
|
||||
docRemoved: Slot<string>;
|
||||
};
|
||||
|
||||
createDoc(options?: CreateBlocksOptions): Blocks;
|
||||
getDoc(docId: string, options?: GetBlocksOptions): Blocks | null;
|
||||
removeDoc(docId: string): void;
|
||||
|
||||
dispose(): void;
|
||||
}
|
||||
Reference in New Issue
Block a user