mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
274
blocksuite/framework/store/src/schema/base.ts
Normal file
274
blocksuite/framework/store/src/schema/base.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { type Disposable, Slot } from '@blocksuite/global/utils';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import type * as Y from 'yjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Boxed } from '../reactive/boxed.js';
|
||||
import { Text } from '../reactive/text.js';
|
||||
import type { YBlock } from '../store/doc/block/index.js';
|
||||
import type { Doc } from '../store/index.js';
|
||||
import type { BaseBlockTransformer } from '../transformer/base.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;
|
||||
}
|
||||
|
||||
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 FIXME: ts error
|
||||
export class BlockModel<
|
||||
Props extends object = object,
|
||||
PropsSignal extends object = SignaledProps<Props>,
|
||||
> extends MagicProps()<PropsSignal> {
|
||||
private _children = signal<string[]>([]);
|
||||
|
||||
/**
|
||||
* @deprecated use doc instead
|
||||
*/
|
||||
page!: Doc;
|
||||
|
||||
private _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 _onCreated: Disposable;
|
||||
|
||||
private _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: Doc) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
20
blocksuite/framework/store/src/schema/error.ts
Normal file
20
blocksuite/framework/store/src/schema/error.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
export class MigrationError extends BlockSuiteError {
|
||||
constructor(description: string) {
|
||||
super(
|
||||
ErrorCode.MigrationError,
|
||||
`Migration failed. Please report to https://github.com/toeverything/blocksuite/issues
|
||||
${description}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaValidateError extends BlockSuiteError {
|
||||
constructor(flavour: string, message: string) {
|
||||
super(
|
||||
ErrorCode.SchemaValidateError,
|
||||
`Invalid schema for ${flavour}: ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
2
blocksuite/framework/store/src/schema/index.ts
Normal file
2
blocksuite/framework/store/src/schema/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './base.js';
|
||||
export { Schema } from './schema.js';
|
||||
182
blocksuite/framework/store/src/schema/schema.ts
Normal file
182
blocksuite/framework/store/src/schema/schema.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { minimatch } from 'minimatch';
|
||||
|
||||
import { SCHEMA_NOT_FOUND_MESSAGE } from '../consts.js';
|
||||
import type { BlockSchemaType } from './base.js';
|
||||
import { BlockSchema } from './base.js';
|
||||
import { SchemaValidateError } from './error.js';
|
||||
|
||||
export class Schema {
|
||||
readonly flavourSchemaMap = new Map<string, BlockSchemaType>();
|
||||
|
||||
validate = (
|
||||
flavour: string,
|
||||
parentFlavour?: string,
|
||||
childFlavours?: string[]
|
||||
): void => {
|
||||
const schema = this.flavourSchemaMap.get(flavour);
|
||||
if (!schema) {
|
||||
throw new SchemaValidateError(flavour, SCHEMA_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
|
||||
const validateChildren = () => {
|
||||
childFlavours?.forEach(childFlavour => {
|
||||
const childSchema = this.flavourSchemaMap.get(childFlavour);
|
||||
if (!childSchema) {
|
||||
throw new SchemaValidateError(childFlavour, SCHEMA_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
this.validateSchema(childSchema, schema);
|
||||
});
|
||||
};
|
||||
|
||||
if (schema.model.role === 'root') {
|
||||
if (parentFlavour) {
|
||||
throw new SchemaValidateError(
|
||||
schema.model.flavour,
|
||||
'Root block cannot have parent.'
|
||||
);
|
||||
}
|
||||
|
||||
validateChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parentFlavour) {
|
||||
throw new SchemaValidateError(
|
||||
schema.model.flavour,
|
||||
'Hub/Content must have parent.'
|
||||
);
|
||||
}
|
||||
|
||||
const parentSchema = this.flavourSchemaMap.get(parentFlavour);
|
||||
if (!parentSchema) {
|
||||
throw new SchemaValidateError(parentFlavour, SCHEMA_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
this.validateSchema(schema, parentSchema);
|
||||
validateChildren();
|
||||
};
|
||||
|
||||
get versions() {
|
||||
return Object.fromEntries(
|
||||
Array.from(this.flavourSchemaMap.values()).map(
|
||||
(schema): [string, number] => [schema.model.flavour, schema.version]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _matchFlavour(childFlavour: string, parentFlavour: string) {
|
||||
return (
|
||||
minimatch(childFlavour, parentFlavour) ||
|
||||
minimatch(parentFlavour, childFlavour)
|
||||
);
|
||||
}
|
||||
|
||||
private _validateParent(
|
||||
child: BlockSchemaType,
|
||||
parent: BlockSchemaType
|
||||
): boolean {
|
||||
const _childFlavour = child.model.flavour;
|
||||
const _parentFlavour = parent.model.flavour;
|
||||
|
||||
const childValidFlavours = child.model.parent || ['*'];
|
||||
const parentValidFlavours = parent.model.children || ['*'];
|
||||
|
||||
return parentValidFlavours.some(parentValidFlavour => {
|
||||
return childValidFlavours.some(childValidFlavour => {
|
||||
if (parentValidFlavour === '*' && childValidFlavour === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parentValidFlavour === '*') {
|
||||
return this._matchFlavour(childValidFlavour, _parentFlavour);
|
||||
}
|
||||
|
||||
if (childValidFlavour === '*') {
|
||||
return this._matchFlavour(_childFlavour, parentValidFlavour);
|
||||
}
|
||||
|
||||
return (
|
||||
this._matchFlavour(_childFlavour, parentValidFlavour) &&
|
||||
this._matchFlavour(childValidFlavour, _parentFlavour)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _validateRole(child: BlockSchemaType, parent: BlockSchemaType) {
|
||||
const childRole = child.model.role;
|
||||
const parentRole = parent.model.role;
|
||||
const childFlavour = child.model.flavour;
|
||||
const parentFlavour = parent.model.flavour;
|
||||
|
||||
if (childRole === 'root') {
|
||||
throw new SchemaValidateError(
|
||||
childFlavour,
|
||||
`Root block cannot have parent: ${parentFlavour}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (childRole === 'hub' && parentRole === 'content') {
|
||||
throw new SchemaValidateError(
|
||||
childFlavour,
|
||||
`Hub block cannot be child of content block: ${parentFlavour}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (childRole === 'content' && parentRole === 'root') {
|
||||
throw new SchemaValidateError(
|
||||
childFlavour,
|
||||
`Content block can only be child of hub block or itself. But get: ${parentFlavour}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
isValid(child: string, parent: string) {
|
||||
const childSchema = this.flavourSchemaMap.get(child);
|
||||
const parentSchema = this.flavourSchemaMap.get(parent);
|
||||
if (!childSchema || !parentSchema) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
this.validateSchema(childSchema, parentSchema);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
register(blockSchema: BlockSchemaType[]) {
|
||||
blockSchema.forEach(schema => {
|
||||
BlockSchema.parse(schema);
|
||||
this.flavourSchemaMap.set(schema.model.flavour, schema);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return Object.fromEntries(
|
||||
Array.from(this.flavourSchemaMap.values()).map(
|
||||
(schema): [string, Record<string, unknown>] => [
|
||||
schema.model.flavour,
|
||||
{
|
||||
role: schema.model.role,
|
||||
parent: schema.model.parent,
|
||||
children: schema.model.children,
|
||||
},
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
validateSchema(child: BlockSchemaType, parent: BlockSchemaType) {
|
||||
this._validateRole(child, parent);
|
||||
|
||||
const relationCheckSuccess = this._validateParent(child, parent);
|
||||
|
||||
if (!relationCheckSuccess) {
|
||||
throw new SchemaValidateError(
|
||||
child.model.flavour,
|
||||
`Block cannot have parent: ${parent.model.flavour}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user