refactor(editor): reorg code structure of store package (#9525)

This commit is contained in:
Saul-Mirone
2025-01-05 12:49:02 +00:00
parent 1180e9bc15
commit 3d168ba2d2
55 changed files with 618 additions and 635 deletions
@@ -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;
}