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;
}