chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,58 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`model to snapshot 1`] = `
{
"flavour": "page",
"id": "0",
"props": {
"count": 3,
"items": [
{
"content": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "item 1",
},
],
},
"id": 0,
},
{
"content": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "item 2",
},
],
},
"id": 1,
},
{
"content": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "item 3",
},
],
},
"id": 2,
},
],
"style": {
"color": "red",
},
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "doc title",
},
],
},
},
"version": 1,
}
`;
@@ -0,0 +1,77 @@
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { describe, expect, test } from 'vitest';
import { getAssetName } from '../adapter/assets.js';
describe('getAssetName', () => {
test('should return the name if it exists', () => {
const assets = new Map<string, Blob>([
['blobId', new File([], 'image.png', { type: 'image/png' })],
['blobId2', new File([], 'image', { type: 'image/png' })],
// inconsistent name with type
['blobId3', new File([], 'image.jpg', { type: 'image/png' })],
// empty name
['blobId4', new File([], '', { type: 'image/png' })],
]);
expect(getAssetName(assets, 'blobId')).toBe('image.png');
expect(getAssetName(assets, 'blobId2')).toBe('image.png');
// respect the original name
expect(getAssetName(assets, 'blobId3')).toBe('image.jpg');
expect(getAssetName(assets, 'blobId4')).toBe('blobId4.png');
});
test('should return blobId with extension if name does not exist', () => {
const assets = new Map<string, Blob>([
['blobId', new Blob([], { type: 'image/jpeg' })],
]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('blobId.jpeg');
});
test('should return the name if it exists but type is empty', () => {
const assets = new Map<string, Blob>([
['blobId', new File([], 'document.test', { type: '' })],
]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('document.test');
});
test('should return the original name even not ext found', () => {
const assets = new Map<string, Blob>([['blobId', new File([], 'blob.')]]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('blob.');
});
test('should return blobId with "blob" extension if type is empty', () => {
const assets = new Map<string, Blob>([
['blobId', new Blob([])],
['blobId2', new Blob([], { type: '' })],
]);
expect(getAssetName(assets, 'blobId')).toBe('blobId.blob');
expect(getAssetName(assets, 'blobId2')).toBe('blobId2.blob');
});
test('should return blobId with last part of mime type if extension is not found', () => {
const assets = new Map<string, Blob>([
['blobId', new Blob([], { type: 'application/unknown' })],
]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('blobId.unknown');
});
test('should return blobId with bin if type is octet-stream', () => {
const assets = new Map<string, Blob>([
['blobId', new Blob([], { type: 'application/octet-stream' })],
]);
const result = getAssetName(assets, 'blobId');
expect(result).toBe('blobId.bin');
});
test('should throw BlockSuiteError if blob is not found', () => {
const assets = new Map<string, Blob>();
expect(() => getAssetName(assets, 'blobId')).toThrow(BlockSuiteError);
expect(() => getAssetName(assets, 'blobId')).toThrowError(
'blob not found for blobId: blobId'
);
});
});
@@ -0,0 +1,249 @@
import { computed, effect } from '@preact/signals-core';
import { describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import {
defineBlockSchema,
internalPrimitives,
Schema,
type SchemaToModel,
} from '../schema/index.js';
import { Block, type YBlock } from '../store/doc/block/index.js';
import { DocCollection, IdGeneratorType } from '../store/index.js';
const pageSchema = defineBlockSchema({
flavour: 'page',
props: internal => ({
title: internal.Text(),
count: 0,
toggle: false,
style: {} as Record<string, unknown>,
boxed: internal.Boxed(new Y.Map()),
}),
metadata: {
role: 'root',
version: 1,
},
});
type RootModel = SchemaToModel<typeof pageSchema>;
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register([pageSchema]);
return { id: 'test-collection', idGenerator, schema };
}
const defaultDocId = 'doc:home';
function createTestDoc(docId = defaultDocId) {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: docId });
doc.load();
return doc;
}
test('init block without props should add default props', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const block = new Block(doc.schema, yBlock, doc);
const model = block.model as RootModel;
expect(yBlock.get('prop:count')).toBe(0);
expect(model.count).toBe(0);
expect(model.style).toEqual({});
});
describe('block model should has signal props', () => {
test('atom', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const block = new Block(doc.schema, yBlock, doc);
const model = block.model as RootModel;
const isOdd = computed(() => model.count$.value % 2 === 1);
expect(model.count$.value).toBe(0);
expect(isOdd.peek()).toBe(false);
// set prop
model.count = 1;
expect(model.count$.value).toBe(1);
expect(isOdd.peek()).toBe(true);
expect(yBlock.get('prop:count')).toBe(1);
// set signal
model.count$.value = 2;
expect(model.count).toBe(2);
expect(isOdd.peek()).toBe(false);
expect(yBlock.get('prop:count')).toBe(2);
// set prop
yBlock.set('prop:count', 3);
expect(model.count).toBe(3);
expect(model.count$.value).toBe(3);
expect(isOdd.peek()).toBe(true);
const toggleEffect = vi.fn();
effect(() => {
toggleEffect(model.toggle$.value);
});
expect(toggleEffect).toHaveBeenCalledTimes(1);
const runToggle = () => {
const next = !model.toggle;
model.toggle = next;
expect(model.toggle$.value).toBe(next);
};
const times = 10;
for (let i = 0; i < times; i++) {
runToggle();
}
expect(toggleEffect).toHaveBeenCalledTimes(times + 1);
const runToggleReverse = () => {
const next = !model.toggle;
model.toggle$.value = next;
expect(model.toggle).toBe(next);
};
for (let i = 0; i < times; i++) {
runToggleReverse();
}
expect(toggleEffect).toHaveBeenCalledTimes(times * 2 + 1);
});
test('nested', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const block = new Block(doc.schema, yBlock, doc);
const model = block.model as RootModel;
expect(model.style).toEqual({});
model.style = { color: 'red' };
expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({
color: 'red',
});
expect(model.style$.value).toEqual({ color: 'red' });
model.style.color = 'yellow';
expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({
color: 'yellow',
});
expect(model.style$.value).toEqual({ color: 'yellow' });
model.style$.value = { color: 'blue' };
expect(model.style.color).toBe('blue');
expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({
color: 'blue',
});
const map = new Y.Map();
map.set('color', 'green');
yBlock.set('prop:style', map);
expect(model.style.color).toBe('green');
expect(model.style$.value).toEqual({ color: 'green' });
});
test('with stash and pop', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const block = new Block(doc.schema, yBlock, doc);
const model = block.model as RootModel;
expect(model.count).toBe(0);
model.stash('count');
model.count = 1;
expect(model.count$.value).toBe(1);
expect(yBlock.get('prop:count')).toBe(0);
model.count$.value = 2;
expect(model.count).toBe(2);
expect(yBlock.get('prop:count')).toBe(0);
model.pop('count');
expect(yBlock.get('prop:count')).toBe(2);
expect(model.count).toBe(2);
expect(model.count$.value).toBe(2);
model.stash('count');
yBlock.set('prop:count', 3);
expect(model.count).toBe(3);
expect(model.count$.value).toBe(3);
model.count$.value = 4;
expect(yBlock.get('prop:count')).toBe(3);
expect(model.count).toBe(4);
model.pop('count');
expect(yBlock.get('prop:count')).toBe(4);
});
});
test('on change', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const onPropsUpdated = vi.fn();
const block = new Block(doc.schema, yBlock, doc, {
onChange: onPropsUpdated,
});
const model = block.model as RootModel;
model.title = internalPrimitives.Text('abc');
expect(onPropsUpdated).toHaveBeenCalledWith(
expect.anything(),
'title',
expect.anything()
);
expect(model.title$.value.toDelta()).toEqual([{ insert: 'abc' }]);
onPropsUpdated.mockClear();
model.title.insert('d', 1);
expect(onPropsUpdated).toHaveBeenCalledWith(
expect.anything(),
'title',
expect.anything()
);
expect(model.title$.value.toDelta()).toEqual([{ insert: 'adbc' }]);
onPropsUpdated.mockClear();
model.boxed.getValue()!.set('foo', 0);
expect(onPropsUpdated).toHaveBeenCalledWith(
expect.anything(),
'boxed',
expect.anything()
);
expect(onPropsUpdated.mock.calls[0][2].toJSON().value).toMatchObject({
foo: 0,
});
expect(model.boxed$.value.getValue()!.toJSON()).toEqual({
foo: 0,
});
});
@@ -0,0 +1,954 @@
// checkout https://vitest.dev/guide/debugging.html for debugging tests
import type { Slot } from '@blocksuite/global/utils';
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { COLLECTION_VERSION, PAGE_VERSION } from '../consts.js';
import type { BlockModel, BlockSchemaType, Doc } from '../index.js';
import { DocCollection, IdGeneratorType, Schema } from '../index.js';
import type { DocMeta } from '../store/index.js';
import type { BlockSuiteDoc } from '../yjs/index.js';
import {
NoteBlockSchema,
ParagraphBlockSchema,
RootBlockSchema,
} from './test-schema.js';
import { assertExists } from './test-utils-dom.js';
export const BlockSchemas = [
ParagraphBlockSchema,
RootBlockSchema,
NoteBlockSchema,
] as BlockSchemaType[];
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register(BlockSchemas);
return { id: 'test-collection', idGenerator, schema };
}
const defaultDocId = 'doc:home';
const spaceId = defaultDocId;
const spaceMetaId = 'meta';
function serializCollection(doc: BlockSuiteDoc): Record<string, any> {
const spaces = {};
doc.spaces.forEach((subDoc, key) => {
// @ts-expect-error FIXME: ts error
spaces[key] = subDoc.toJSON();
});
const json = doc.toJSON();
delete json.spaces;
return {
...json,
spaces,
};
}
function waitOnce<T>(slot: Slot<T>) {
return new Promise<T>(resolve => slot.once(val => resolve(val)));
}
function createRoot(doc: Doc) {
doc.addBlock('affine:page');
if (!doc.root) throw new Error('root not found');
return doc.root;
}
function createTestDoc(docId = defaultDocId) {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: docId });
doc.load();
return doc;
}
function requestIdleCallbackPolyfill(
callback: IdleRequestCallback,
options?: IdleRequestOptions
) {
const timeout = options?.timeout ?? 1000;
const start = Date.now();
return setTimeout(function () {
callback({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, timeout - (Date.now() - start));
},
});
}, timeout) as unknown as number;
}
beforeEach(() => {
if (globalThis.requestIdleCallback === undefined) {
globalThis.requestIdleCallback = requestIdleCallbackPolyfill;
}
});
describe('basic', () => {
it('can init collection', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
assert.equal(collection.isEmpty, true);
const doc = collection.createDoc({ id: 'doc:home' });
doc.load();
const actual = serializCollection(collection.doc);
const actualDoc = actual[spaceMetaId].pages[0] as DocMeta;
assert.equal(collection.isEmpty, false);
assert.equal(typeof actualDoc.createDate, 'number');
// @ts-expect-error FIXME: ts error
delete actualDoc.createDate;
assert.deepEqual(actual, {
[spaceMetaId]: {
pages: [
{
id: 'doc:home',
title: '',
tags: [],
},
],
workspaceVersion: COLLECTION_VERSION,
pageVersion: PAGE_VERSION,
blockVersions: {
'affine:note': 1,
'affine:page': 2,
'affine:paragraph': 1,
},
},
spaces: {
[spaceId]: {
blocks: {},
},
},
});
});
it('init collection with custom id generator', () => {
const options = createTestOptions();
let id = 100;
const collection = new DocCollection({
...options,
idGenerator: () => {
return String(id++);
},
});
collection.meta.initialize();
{
const doc = collection.createDoc();
assert.equal(doc.id, '100');
}
{
const doc = collection.createDoc();
assert.equal(doc.id, '101');
}
});
it('doc ready lifecycle', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({
id: 'space:0',
});
const readyCallback = vi.fn();
const rootAddedCallback = vi.fn();
doc.slots.ready.on(readyCallback);
doc.slots.rootAdded.on(rootAddedCallback);
doc.load(() => {
expect(doc.ready).toBe(false);
const rootId = doc.addBlock('affine:page', {
title: new doc.Text(),
});
expect(rootAddedCallback).toBeCalledTimes(1);
expect(doc.ready).toBe(false);
doc.addBlock('affine:note', {}, rootId);
});
expect(doc.ready).toBe(true);
expect(readyCallback).toBeCalledTimes(1);
});
it('collection docs with yjs applyUpdate', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const collection2 = new DocCollection(options);
const doc = collection.createDoc({
id: 'space:0',
});
doc.load(() => {
doc.addBlock('affine:page', {
title: new doc.Text(),
});
});
{
const subdocsTester = vi.fn(({ added }) => {
expect(added.size).toBe(1);
});
// only apply root update
collection2.doc.once('subdocs', subdocsTester);
expect(subdocsTester).toBeCalledTimes(0);
expect(collection2.docs.size).toBe(0);
const update = encodeStateAsUpdate(collection.doc);
applyUpdate(collection2.doc, update);
expect(collection2.doc.toJSON()['spaces']).toEqual({
'space:0': {
blocks: {},
},
});
expect(collection2.docs.size).toBe(1);
expect(subdocsTester).toBeCalledTimes(1);
}
{
// apply doc update
const update = encodeStateAsUpdate(doc.spaceDoc);
expect(collection2.docs.size).toBe(1);
const doc2 = collection2.getDoc('space:0');
assertExists(doc2);
applyUpdate(doc2.spaceDoc, update);
expect(collection2.doc.toJSON()['spaces']).toEqual({
'space:0': {
blocks: {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': [],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
},
},
});
const fn = vi.fn(({ loaded }) => {
expect(loaded.size).toBe(1);
});
collection2.doc.once('subdocs', fn);
expect(fn).toBeCalledTimes(0);
doc2.load();
expect(fn).toBeCalledTimes(1);
}
});
});
describe('addBlock', () => {
it('can add single model', () => {
const doc = createTestDoc();
doc.addBlock('affine:page', {
title: new doc.Text(),
});
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': [],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
});
});
it('can add model with props', () => {
const doc = createTestDoc();
doc.addBlock('affine:page', { title: new doc.Text('hello') });
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'sys:children': [],
'sys:flavour': 'affine:page',
'sys:id': '0',
'prop:title': 'hello',
'sys:version': 2,
},
});
});
it('can add multi models', () => {
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {
title: new doc.Text(),
});
const noteId = doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlocks(
[
{ flavour: 'affine:paragraph', blockProps: { type: 'h1' } },
{ flavour: 'affine:paragraph', blockProps: { type: 'h2' } },
],
noteId
);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'prop:title': '',
'sys:version': 2,
},
'1': {
'sys:children': ['2', '3', '4'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'prop:text': '',
'prop:type': 'text',
'sys:version': 1,
},
'3': {
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'prop:text': '',
'prop:type': 'h1',
'sys:version': 1,
},
'4': {
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'prop:text': '',
'prop:type': 'h2',
'sys:version': 1,
},
});
});
it('can observe slot events', async () => {
const doc = createTestDoc();
queueMicrotask(() =>
doc.addBlock('affine:page', {
title: new doc.Text(),
})
);
const blockId = await waitOnce(doc.slots.rootAdded);
const block = doc.getBlockById(blockId) as BlockModel;
assert.equal(block.flavour, 'affine:page');
});
it('can add block to root', async () => {
const doc = createTestDoc();
let noteId: string;
queueMicrotask(() => {
const rootId = doc.addBlock('affine:page');
noteId = doc.addBlock('affine:note', {}, rootId);
});
await waitOnce(doc.slots.rootAdded);
const { root } = doc;
if (!root) throw new Error('root is null');
assert.equal(root.flavour, 'affine:page');
doc.addBlock('affine:paragraph', {}, noteId!);
assert.equal(root.children[0].flavour, 'affine:note');
assert.equal(root.children[0].children[0].flavour, 'affine:paragraph');
assert.equal(root.childMap.value.get('1'), 0);
const serializedChildren = serializCollection(doc.rootDoc).spaces[spaceId]
.blocks['0']['sys:children'];
assert.deepEqual(serializedChildren, ['1']);
assert.equal(root.children[0].id, '1');
});
it('can add and remove multi docs', async () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc0 = collection.createDoc({ id: 'doc:home' });
const doc1 = collection.createDoc({ id: 'space:doc1' });
await Promise.all([doc0.load(), doc1.load()]);
assert.equal(collection.docs.size, 2);
doc0.addBlock('affine:page', {
title: new doc0.Text(),
});
collection.removeDoc(doc0.id);
assert.equal(collection.docs.size, 1);
assert.equal(
serializCollection(doc0.rootDoc).spaces['doc:home'],
undefined
);
collection.removeDoc(doc1.id);
assert.equal(collection.docs.size, 0);
});
it('can remove doc that has not been loaded', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc0 = collection.createDoc({ id: 'doc:home' });
collection.removeDoc(doc0.id);
assert.equal(collection.docs.size, 0);
});
it('can set doc state', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
collection.createDoc({ id: 'doc:home' });
assert.deepEqual(
collection.meta.docMetas.map(({ id, title }) => ({
id,
title,
})),
[
{
id: 'doc:home',
title: '',
},
]
);
let called = false;
collection.meta.docMetaUpdated.on(() => {
called = true;
});
collection.setDocMeta('doc:home', { favorite: true });
assert.deepEqual(
collection.meta.docMetas.map(({ id, title, favorite }) => ({
id,
title,
favorite,
})),
[
{
id: 'doc:home',
title: '',
favorite: true,
},
]
);
assert.ok(called);
});
it('can set collection common meta fields', async () => {
const options = createTestOptions();
const collection = new DocCollection(options);
queueMicrotask(() => collection.meta.setName('hello'));
await waitOnce(collection.meta.commonFieldsUpdated);
assert.deepEqual(collection.meta.name, 'hello');
queueMicrotask(() => collection.meta.setAvatar('gengar.jpg'));
await waitOnce(collection.meta.commonFieldsUpdated);
assert.deepEqual(collection.meta.avatar, 'gengar.jpg');
});
});
describe('deleteBlock', () => {
it('delete children recursively by default', () => {
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['2', '3'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
});
const deletedModel = doc.getBlockById('1') as BlockModel;
doc.deleteBlock(deletedModel);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': [],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
});
});
it('bring children to parent', () => {
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
const p1 = doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, p1);
doc.addBlock('affine:paragraph', {}, p1);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['2'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'prop:text': '',
'prop:type': 'text',
'sys:children': ['3', '4'],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
'4': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'sys:version': 1,
},
});
const deletedModel = doc.getBlockById('2') as BlockModel;
const deletedModelParent = doc.getBlockById('1') as BlockModel;
doc.deleteBlock(deletedModel, {
bringChildrenTo: deletedModelParent,
});
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['3', '4'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
'4': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'sys:version': 1,
},
});
});
it('bring children to other block', () => {
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
const p1 = doc.addBlock('affine:paragraph', {}, noteId);
const p2 = doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, p1);
doc.addBlock('affine:paragraph', {}, p1);
doc.addBlock('affine:paragraph', {}, p2);
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['2', '3'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'prop:text': '',
'prop:type': 'text',
'sys:children': ['4', '5'],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': ['6'],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
'4': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'sys:version': 1,
},
'5': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '5',
'sys:version': 1,
},
'6': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '6',
'sys:version': 1,
},
});
const deletedModel = doc.getBlockById('2') as BlockModel;
const moveToModel = doc.getBlockById('3') as BlockModel;
doc.deleteBlock(deletedModel, {
bringChildrenTo: moveToModel,
});
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['3'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'3': {
'prop:text': '',
'prop:type': 'text',
'sys:children': ['6', '4', '5'],
'sys:flavour': 'affine:paragraph',
'sys:id': '3',
'sys:version': 1,
},
'4': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '4',
'sys:version': 1,
},
'5': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '5',
'sys:version': 1,
},
'6': {
'prop:text': '',
'prop:type': 'text',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '6',
'sys:version': 1,
},
});
});
it('can delete model with parent', () => {
const doc = createTestDoc();
const rootModel = createRoot(doc);
const noteId = doc.addBlock('affine:note', {}, rootModel.id);
doc.addBlock('affine:paragraph', {}, noteId);
// before delete
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': ['2'],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
'2': {
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'sys:id': '2',
'prop:text': '',
'prop:type': 'text',
'sys:version': 1,
},
});
doc.deleteBlock(rootModel.children[0].children[0]);
// after delete
assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, {
'0': {
'prop:count': 0,
'prop:items': [],
'prop:style': {},
'prop:title': '',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
'sys:id': '0',
'sys:version': 2,
},
'1': {
'sys:children': [],
'sys:flavour': 'affine:note',
'sys:id': '1',
'sys:version': 1,
},
});
assert.equal(rootModel.children.length, 1);
});
});
describe('getBlock', () => {
it('can get block by id', () => {
const doc = createTestDoc();
const rootModel = createRoot(doc);
const noteId = doc.addBlock('affine:note', {}, rootModel.id);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
const text = doc.getBlockById('3') as BlockModel;
assert.equal(text.flavour, 'affine:paragraph');
assert.equal(rootModel.children[0].children.indexOf(text), 1);
const invalid = doc.getBlockById('😅');
assert.equal(invalid, null);
});
it('can get parent', () => {
const doc = createTestDoc();
const rootModel = createRoot(doc);
const noteId = doc.addBlock('affine:note', {}, rootModel.id);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
const result = doc.getParent(
rootModel.children[0].children[1]
) as BlockModel;
assert.equal(result, rootModel.children[0]);
const invalid = doc.getParent(rootModel);
assert.equal(invalid, null);
});
it('can get previous sibling', () => {
const doc = createTestDoc();
const rootModel = createRoot(doc);
const noteId = doc.addBlock('affine:note', {}, rootModel.id);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
const result = doc.getPrev(rootModel.children[0].children[1]) as BlockModel;
assert.equal(result, rootModel.children[0].children[0]);
const invalid = doc.getPrev(rootModel.children[0].children[0]);
assert.equal(invalid, null);
});
});
// Inline snapshot is not supported under describe.parallel config
describe('collection.exportJSX works', () => {
it('collection matches snapshot', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'doc:home' });
doc.addBlock('affine:page', { title: new doc.Text('hello') });
expect(collection.exportJSX()).toMatchInlineSnapshot(`
<affine:page
prop:count={0}
prop:items={[]}
prop:style={{}}
prop:title="hello"
/>
`);
});
it('empty collection matches snapshot', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
collection.createDoc({ id: 'doc:home' });
expect(collection.exportJSX()).toMatchInlineSnapshot('null');
});
it('collection with multiple blocks children matches snapshot', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'doc:home' });
doc.load(() => {
const rootId = doc.addBlock('affine:page', {
title: new doc.Text(),
});
const noteId = doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, noteId);
});
expect(collection.exportJSX()).toMatchInlineSnapshot(/* xml */ `
<affine:page
prop:count={0}
prop:items={[]}
prop:style={{}}
>
<affine:note>
<affine:paragraph
prop:type="text"
/>
<affine:paragraph
prop:type="text"
/>
</affine:note>
</affine:page>
`);
});
});
describe('flags', () => {
it('update flags', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const awareness = collection.awarenessStore;
awareness.setFlag('enable_lasso_tool', false);
expect(awareness.getFlag('enable_lasso_tool')).toBe(false);
awareness.setFlag('enable_lasso_tool', true);
expect(awareness.getFlag('enable_lasso_tool')).toBe(true);
});
});
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:page': BlockModel;
'affine:paragraph': BlockModel;
'affine:note': BlockModel;
}
}
}
@@ -0,0 +1,272 @@
import { expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import { Schema } from '../schema/index.js';
import {
BlockViewType,
DocCollection,
IdGeneratorType,
} from '../store/index.js';
import {
DividerBlockSchema,
ListBlockSchema,
NoteBlockSchema,
ParagraphBlockSchema,
type RootBlockModel,
RootBlockSchema,
} from './test-schema.js';
const BlockSchemas = [
RootBlockSchema,
ParagraphBlockSchema,
ListBlockSchema,
NoteBlockSchema,
DividerBlockSchema,
];
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register(BlockSchemas);
return { id: 'test-collection', idGenerator, schema };
}
test('trigger props updated', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('affine:page');
const rootModel = doc.root as RootBlockModel;
expect(rootModel).not.toBeNull();
const onPropsUpdated = vi.fn();
rootModel.propsUpdated.on(onPropsUpdated);
const getColor = () =>
(rootModel.yBlock.get('prop:style') as Y.Map<string>).get('color');
const getItems = () => rootModel.yBlock.get('prop:items') as Y.Array<unknown>;
const getCount = () => rootModel.yBlock.get('prop:count');
rootModel.count = 1;
expect(onPropsUpdated).toBeCalledTimes(1);
expect(onPropsUpdated).toHaveBeenNthCalledWith(1, { key: 'count' });
expect(getCount()).toBe(1);
rootModel.count = 2;
expect(onPropsUpdated).toBeCalledTimes(2);
expect(onPropsUpdated).toHaveBeenNthCalledWith(2, { key: 'count' });
expect(getCount()).toBe(2);
rootModel.style.color = 'blue';
expect(onPropsUpdated).toBeCalledTimes(3);
expect(onPropsUpdated).toHaveBeenNthCalledWith(3, { key: 'style' });
expect(getColor()).toBe('blue');
rootModel.style = { color: 'red' };
expect(onPropsUpdated).toBeCalledTimes(4);
expect(onPropsUpdated).toHaveBeenNthCalledWith(4, { key: 'style' });
expect(getColor()).toBe('red');
rootModel.style.color = 'green';
expect(onPropsUpdated).toBeCalledTimes(5);
expect(onPropsUpdated).toHaveBeenNthCalledWith(5, { key: 'style' });
expect(getColor()).toBe('green');
rootModel.items.push(1);
expect(onPropsUpdated).toBeCalledTimes(6);
expect(onPropsUpdated).toHaveBeenNthCalledWith(6, { key: 'items' });
expect(getItems().get(0)).toBe(1);
rootModel.items[0] = { id: '1' };
expect(onPropsUpdated).toBeCalledTimes(7);
expect(onPropsUpdated).toHaveBeenNthCalledWith(7, { key: 'items' });
expect(getItems().get(0)).toBeInstanceOf(Y.Map);
expect((getItems().get(0) as Y.Map<unknown>).get('id')).toBe('1');
});
test('stash and pop', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('affine:page');
const rootModel = doc.root as RootBlockModel;
expect(rootModel).not.toBeNull();
const onPropsUpdated = vi.fn();
rootModel.propsUpdated.on(onPropsUpdated);
const getCount = () => rootModel.yBlock.get('prop:count');
const getColor = () =>
(rootModel.yBlock.get('prop:style') as Y.Map<string>).get('color');
rootModel.count = 1;
expect(onPropsUpdated).toBeCalledTimes(1);
expect(onPropsUpdated).toHaveBeenNthCalledWith(1, { key: 'count' });
expect(getCount()).toBe(1);
rootModel.stash('count');
rootModel.count = 2;
expect(onPropsUpdated).toBeCalledTimes(3);
expect(onPropsUpdated).toHaveBeenNthCalledWith(3, { key: 'count' });
expect(rootModel.yBlock.get('prop:count')).toBe(1);
rootModel.pop('count');
expect(onPropsUpdated).toBeCalledTimes(4);
expect(onPropsUpdated).toHaveBeenNthCalledWith(4, { key: 'count' });
expect(rootModel.yBlock.get('prop:count')).toBe(2);
rootModel.style.color = 'blue';
expect(getColor()).toBe('blue');
expect(onPropsUpdated).toBeCalledTimes(5);
expect(onPropsUpdated).toHaveBeenNthCalledWith(5, { key: 'style' });
rootModel.stash('style');
rootModel.style = {
color: 'red',
};
expect(getColor()).toBe('blue');
expect(onPropsUpdated).toBeCalledTimes(7);
expect(onPropsUpdated).toHaveBeenNthCalledWith(7, { key: 'style' });
rootModel.pop('style');
expect(getColor()).toBe('red');
expect(onPropsUpdated).toBeCalledTimes(8);
expect(onPropsUpdated).toHaveBeenNthCalledWith(8, { key: 'style' });
rootModel.stash('style');
expect(onPropsUpdated).toBeCalledTimes(9);
expect(onPropsUpdated).toHaveBeenNthCalledWith(9, { key: 'style' });
rootModel.style.color = 'green';
expect(onPropsUpdated).toBeCalledTimes(10);
expect(onPropsUpdated).toHaveBeenNthCalledWith(10, { key: 'style' });
expect(getColor()).toBe('red');
rootModel.pop('style');
expect(getColor()).toBe('green');
expect(onPropsUpdated).toBeCalledTimes(11);
expect(onPropsUpdated).toHaveBeenNthCalledWith(11, { key: 'style' });
});
test('always get latest value in onChange', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('affine:page');
const rootModel = doc.root as RootBlockModel;
expect(rootModel).not.toBeNull();
let value: unknown;
rootModel.propsUpdated.on(({ key }) => {
// @ts-expect-error FIXME: ts error
value = rootModel[key];
});
rootModel.count = 1;
expect(value).toBe(1);
rootModel.stash('count');
rootModel.count = 2;
expect(value).toBe(2);
rootModel.pop('count');
rootModel.count = 3;
expect(value).toBe(3);
rootModel.style.color = 'blue';
expect(value).toEqual({ color: 'blue' });
rootModel.stash('style');
rootModel.style = { color: 'red' };
expect(value).toEqual({ color: 'red' });
rootModel.style.color = 'green';
expect(value).toEqual({ color: 'green' });
rootModel.pop('style');
rootModel.style.color = 'yellow';
expect(value).toEqual({ color: 'yellow' });
});
test('query', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc1 = collection.createDoc({ id: 'home' });
doc1.load();
const doc2 = collection.getDoc('home');
const doc3 = collection.getDoc('home', {
query: {
mode: 'loose',
match: [
{
flavour: 'affine:list',
viewType: BlockViewType.Hidden,
},
],
},
});
expect(doc1).toBe(doc2);
expect(doc1).not.toBe(doc3);
const page = doc1.addBlock('affine:page');
const note = doc1.addBlock('affine:note', {}, page);
const paragraph1 = doc1.addBlock('affine:paragraph', {}, note);
const list1 = doc1.addBlock('affine:list' as never, {}, note);
expect(doc2?.getBlock(paragraph1)?.blockViewType).toBe(BlockViewType.Display);
expect(doc2?.getBlock(list1)?.blockViewType).toBe(BlockViewType.Display);
expect(doc3?.getBlock(list1)?.blockViewType).toBe(BlockViewType.Hidden);
const list2 = doc1.addBlock('affine:list' as never, {}, note);
expect(doc2?.getBlock(list2)?.blockViewType).toBe(BlockViewType.Display);
expect(doc3?.getBlock(list2)?.blockViewType).toBe(BlockViewType.Hidden);
});
test('local readonly', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc1 = collection.createDoc({ id: 'home' });
doc1.load();
const doc2 = collection.getDoc('home', { readonly: true });
const doc3 = collection.getDoc('home', { readonly: false });
expect(doc1.readonly).toBeFalsy();
expect(doc2?.readonly).toBeTruthy();
expect(doc3?.readonly).toBeFalsy();
collection.awarenessStore.setReadonly(doc1.blockCollection, true);
expect(doc1.readonly).toBeTruthy();
expect(doc2?.readonly).toBeTruthy();
expect(doc3?.readonly).toBeTruthy();
collection.awarenessStore.setReadonly(doc1.blockCollection, false);
expect(doc1.readonly).toBeFalsy();
expect(doc2?.readonly).toBeTruthy();
expect(doc3?.readonly).toBeFalsy();
});
@@ -0,0 +1,133 @@
// checkout https://vitest.dev/guide/debugging.html for debugging tests
import { describe, expect, it } from 'vitest';
import { yDocToJSXNode } from '../utils/jsx.js';
describe('basic', () => {
it('serialized doc match snapshot', () => {
expect(
yDocToJSXNode(
{
'0': {
'sys:id': '0',
'sys:children': ['1'],
'sys:flavour': 'affine:page',
},
'1': {
'sys:id': '1',
'sys:children': [],
'sys:flavour': 'affine:paragraph',
'prop:text': [],
'prop:type': 'text',
},
},
'0'
)
).toMatchInlineSnapshot(`
<affine:page>
<affine:paragraph
prop:type="text"
/>
</affine:page>
`);
});
it('block with plain text should match snapshot', () => {
expect(
yDocToJSXNode(
{
'0': {
'sys:id': '0',
'sys:flavour': 'affine:page',
'sys:children': ['1'],
'prop:title': 'this is title',
},
'1': {
'sys:id': '2',
'sys:flavour': 'affine:paragraph',
'sys:children': [],
'prop:type': 'text',
'prop:text': [{ insert: 'just plain text' }],
},
},
'0'
)
).toMatchInlineSnapshot(`
<affine:page
prop:title="this is title"
>
<affine:paragraph
prop:text="just plain text"
prop:type="text"
/>
</affine:page>
`);
});
it('doc record match snapshot', () => {
expect(
yDocToJSXNode(
{
'0': {
'sys:id': '0',
'sys:flavour': 'affine:page',
'sys:children': ['1'],
'prop:title': 'this is title',
},
'1': {
'sys:id': '2',
'sys:flavour': 'affine:paragraph',
'sys:children': [],
'prop:type': 'text',
'prop:text': [
{ insert: 'this is ' },
{
insert: 'a ',
attributes: { link: 'http://www.example.com' },
},
{
insert: 'link',
attributes: { link: 'http://www.example.com', bold: true },
},
{ insert: ' with', attributes: { bold: true } },
{ insert: ' bold' },
],
},
},
'0'
)
).toMatchInlineSnapshot(`
<affine:page
prop:title="this is title"
>
<affine:paragraph
prop:text={
<>
<text
insert="this is "
/>
<text
insert="a "
link="http://www.example.com"
/>
<text
bold={true}
insert="link"
link="http://www.example.com"
/>
<text
bold={true}
insert=" with"
/>
<text
insert=" bold"
/>
</>
}
prop:type="text"
/>
</affine:page>
`);
});
});
@@ -0,0 +1,133 @@
import { literal } from 'lit/static-html.js';
import { describe, expect, it, vi } from 'vitest';
// import some blocks
import { type BlockModel, defineBlockSchema } from '../schema/base.js';
import { SchemaValidateError } from '../schema/error.js';
import { Schema } from '../schema/index.js';
import { DocCollection, IdGeneratorType } from '../store/index.js';
import {
DividerBlockSchema,
ListBlockSchema,
NoteBlockSchema,
ParagraphBlockSchema,
RootBlockSchema,
} from './test-schema.js';
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register(BlockSchemas);
return { id: 'test-collection', idGenerator, schema };
}
const TestCustomNoteBlockSchema = defineBlockSchema({
flavour: 'affine:note-block-video',
props: internal => ({
text: internal.Text(),
}),
metadata: {
version: 1,
role: 'content',
tag: literal`affine-note-block-video`,
parent: ['affine:note'],
},
});
const TestInvalidNoteBlockSchema = defineBlockSchema({
flavour: 'affine:note-invalid-block-video',
props: internal => ({
text: internal.Text(),
}),
metadata: {
version: 1,
role: 'content',
tag: literal`affine-invalid-note-block-video`,
parent: ['affine:note'],
},
});
const BlockSchemas = [
RootBlockSchema,
ParagraphBlockSchema,
ListBlockSchema,
NoteBlockSchema,
DividerBlockSchema,
TestCustomNoteBlockSchema,
TestInvalidNoteBlockSchema,
];
const defaultDocId = 'doc0';
function createTestDoc(docId = defaultDocId) {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: docId });
doc.load();
return doc;
}
describe('schema', () => {
it('should be able to validate schema by role', () => {
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
const paragraphId = doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:note', {});
expect(consoleMock.mock.calls[0]).toSatisfy((call: unknown[]) => {
return typeof call[0] === 'string';
});
expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => {
return call[0] instanceof SchemaValidateError;
});
consoleMock.mockClear();
// add paragraph to root should throw
doc.addBlock('affine:paragraph', {}, rootId);
expect(consoleMock.mock.calls[0]).toSatisfy((call: unknown[]) => {
return typeof call[0] === 'string';
});
expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => {
return call[0] instanceof SchemaValidateError;
});
consoleMock.mockClear();
doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:paragraph', {}, noteId);
doc.addBlock('affine:paragraph', {}, paragraphId);
expect(consoleMock).not.toBeCalled();
});
it('should glob match works', () => {
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const doc = createTestDoc();
const rootId = doc.addBlock('affine:page', {});
const noteId = doc.addBlock('affine:note', {}, rootId);
doc.addBlock('affine:note-block-video', {}, noteId);
expect(consoleMock).not.toBeCalled();
doc.addBlock('affine:note-invalid-block-video', {}, noteId);
expect(consoleMock.mock.calls[0]).toSatisfy((call: unknown[]) => {
return typeof call[0] === 'string';
});
expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => {
return call[0] instanceof SchemaValidateError;
});
});
});
declare global {
namespace BlockSuite {
interface BlockModels {
'affine:note-block-video': BlockModel;
'affine:note-invalid-block-video': BlockModel;
}
}
}
@@ -0,0 +1,88 @@
import { defineBlockSchema, type SchemaToModel } from '../schema/index.js';
export const RootBlockSchema = defineBlockSchema({
flavour: 'affine:page',
props: internal => ({
title: internal.Text(),
count: 0,
style: {} as Record<string, unknown>,
items: [] as unknown[],
}),
metadata: {
version: 2,
role: 'root',
},
});
export type RootBlockModel = SchemaToModel<typeof RootBlockSchema>;
export const NoteBlockSchema = defineBlockSchema({
flavour: 'affine:note',
props: () => ({}),
metadata: {
version: 1,
role: 'hub',
parent: ['affine:page'],
children: [
'affine:paragraph',
'affine:list',
'affine:code',
'affine:divider',
'affine:database',
'affine:data-view',
'affine:image',
'affine:note-block-*',
'affine:bookmark',
'affine:attachment',
'affine:surface-ref',
'affine:embed-*',
],
},
});
export const ParagraphBlockSchema = defineBlockSchema({
flavour: 'affine:paragraph',
props: internal => ({
type: 'text',
text: internal.Text(),
}),
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:database',
'affine:paragraph',
'affine:list',
],
},
});
export const ListBlockSchema = defineBlockSchema({
flavour: 'affine:list',
props: internal => ({
type: 'bulleted',
text: internal.Text(),
checked: false,
collapsed: false,
}),
metadata: {
version: 1,
role: 'content',
parent: [
'affine:note',
'affine:database',
'affine:list',
'affine:paragraph',
],
},
});
export const DividerBlockSchema = defineBlockSchema({
flavour: 'affine:divider',
metadata: {
version: 1,
role: 'content',
children: [],
},
});
@@ -0,0 +1,122 @@
import type { DocCollection } from '../store/index.js';
declare global {
interface WindowEventMap {
'test-result': CustomEvent<TestResult>;
}
interface Window {
collection: DocCollection;
}
}
export interface TestResult {
success: boolean;
messages: string[];
}
const testResult: TestResult = {
success: true,
messages: [],
};
interface TestCase {
name: string;
callback: () => Promise<boolean>;
}
let testCases: TestCase[] = [];
function reportTestResult() {
const event = new CustomEvent<TestResult>('test-result', {
detail: testResult,
});
window.dispatchEvent(event);
}
function addMessage(message: string) {
console.log(message);
testResult.messages.push(message);
}
function reject(message: string) {
testResult.success = false;
addMessage(`${message}`);
}
export function testSerial(name: string, callback: () => Promise<boolean>) {
testCases.push({ name, callback });
}
function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function runOnce() {
await wait(50); // for correct event sequence
for (const testCase of testCases) {
const { name, callback } = testCase;
const result = await callback();
if (result) addMessage(`${name}`);
else reject(name);
}
reportTestResult();
testCases = [];
}
// XXX: workaround typing issue in blobs/__tests__/test-entry.ts
export function assertExists<T>(val: T | null | undefined): asserts val is T {
if (val === null || val === undefined) {
throw new Error('val does not exist');
}
}
export async function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
// Test image source: https://en.wikipedia.org/wiki/Test_card
export async function loadTestImageBlob(name: string): Promise<Blob> {
const resp = await fetch(`/${name}.png`);
return resp.blob();
}
export async function loadImage(blobUrl: string) {
const img = new Image();
img.src = blobUrl;
return new Promise<HTMLImageElement>(resolve => {
img.onload = () => resolve(img);
});
}
export function assertColor(
img: HTMLImageElement,
x: number,
y: number,
color: [number, number, number]
): boolean {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(x, y, 1, 1).data;
const r = data[0];
const g = data[1];
const b = data[2];
return r === color[0] && g === color[1] && b === color[2];
}
// prevent redundant test runs
export function disableButtonsAfterClick() {
const buttons = document.querySelectorAll('button');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
buttons.forEach(button => {
button.disabled = true;
});
});
});
}
@@ -0,0 +1,143 @@
import { expect, test } from 'vitest';
import * as Y from 'yjs';
import { MemoryBlobCRUD } from '../adapter/index.js';
import { Text } from '../reactive/index.js';
import {
type BlockModel,
defineBlockSchema,
Schema,
type SchemaToModel,
} from '../schema/index.js';
import { DocCollection, IdGeneratorType } from '../store/index.js';
import { AssetsManager, BaseBlockTransformer } from '../transformer/index.js';
const docSchema = defineBlockSchema({
flavour: 'page',
props: internal => ({
title: internal.Text('doc title'),
count: 3,
style: {
color: 'red',
},
items: [
{
id: 0,
content: internal.Text('item 1'),
},
{
id: 1,
content: internal.Text('item 2'),
},
{
id: 2,
content: internal.Text('item 3'),
},
],
}),
metadata: {
role: 'root',
version: 1,
},
});
type RootBlockModel = SchemaToModel<typeof docSchema>;
function createTestOptions() {
const idGenerator = IdGeneratorType.AutoIncrement;
const schema = new Schema();
schema.register([docSchema]);
return { id: 'test-collection', idGenerator, schema };
}
const transformer = new BaseBlockTransformer();
const blobCRUD = new MemoryBlobCRUD();
const assets = new AssetsManager({ blob: blobCRUD });
test('model to snapshot', () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('page');
const rootModel = doc.root as RootBlockModel;
expect(rootModel).not.toBeNull();
const snapshot = transformer.toSnapshot({
model: rootModel,
assets,
});
expect(snapshot).toMatchSnapshot();
});
test('snapshot to model', async () => {
const options = createTestOptions();
const collection = new DocCollection(options);
collection.meta.initialize();
const doc = collection.createDoc({ id: 'home' });
doc.load();
doc.addBlock('page');
const rootModel = doc.root as RootBlockModel;
const tempDoc = new Y.Doc();
const map = tempDoc.getMap('temp');
expect(rootModel).not.toBeNull();
const snapshot = transformer.toSnapshot({
model: rootModel,
assets,
});
const model = await transformer.fromSnapshot({
json: snapshot,
assets,
children: [],
});
expect(model.flavour).toBe(rootModel.flavour);
// @ts-expect-error FIXME: ts error
expect(model.props.title).toBeInstanceOf(Text);
// @ts-expect-error FIXME: ts error
map.set('title', model.props.title.yText);
// @ts-expect-error FIXME: ts error
expect(model.props.title.toString()).toBe('doc title');
// @ts-expect-error FIXME: ts error
expect(model.props.style).toEqual({
color: 'red',
});
// @ts-expect-error FIXME: ts error
expect(model.props.count).toBe(3);
// @ts-expect-error FIXME: ts error
expect(model.props.items).toMatchObject([
{
id: 0,
},
{
id: 1,
},
{
id: 2,
},
]);
// @ts-expect-error FIXME: ts error
model.props.items.forEach((item, index) => {
expect(item.content).toBeInstanceOf(Text);
const key = `item:${index}:content`;
map.set(key, item.content.yText);
expect(item.content.toString()).toBe(`item ${index + 1}`);
});
});
declare global {
namespace BlockSuite {
interface BlockModels {
page: BlockModel;
}
}
}
@@ -0,0 +1,177 @@
import { describe, expect, test } from 'vitest';
import * as Y from 'yjs';
import type { Text } from '../reactive/index.js';
import { Boxed, createYProxy, popProp, stashProp } from '../reactive/index.js';
describe('blocksuite yjs', () => {
describe('array', () => {
test('proxy', () => {
const ydoc = new Y.Doc();
const arr = ydoc.getArray('arr');
arr.push([0]);
const proxy = createYProxy(arr) as unknown[];
expect(arr.get(0)).toBe(0);
proxy.push(1);
expect(arr.get(1)).toBe(1);
expect(arr.length).toBe(2);
proxy.splice(1, 1);
expect(arr.length).toBe(1);
proxy[0] = 2;
expect(arr.length).toBe(1);
});
});
describe('object', () => {
test('deep', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const obj = new Y.Map();
obj.set('foo', 1);
map.set('obj', obj);
map.set('num', 0);
const map2 = new Y.Map();
obj.set('map', map2);
map2.set('foo', 40);
const proxy = createYProxy<Record<string, any>>(map);
expect(proxy.num).toBe(0);
expect(proxy.obj.foo).toBe(1);
expect(proxy.obj.map.foo).toBe(40);
proxy.obj.bar = 100;
expect(obj.get('bar')).toBe(100);
proxy.obj2 = { foo: 2, bar: { num: 3 } };
expect(map.get('obj2')).toBeInstanceOf(Y.Map);
// @ts-expect-error FIXME: ts error
expect(map.get('obj2').get('bar').get('num')).toBe(3);
proxy.obj2.bar.str = 'hello';
// @ts-expect-error FIXME: ts error
expect(map.get('obj2').get('bar').get('str')).toBe('hello');
proxy.obj3 = {};
const { obj3 } = proxy;
obj3.id = 'obj3';
expect((map.get('obj3') as Y.Map<string>).get('id')).toBe('obj3');
proxy.arr = [];
expect(map.get('arr')).toBeInstanceOf(Y.Array);
proxy.arr.push({ counter: 1 });
expect((map.get('arr') as Y.Array<Y.Map<number>>).get(0)).toBeInstanceOf(
Y.Map
);
expect(
(map.get('arr') as Y.Array<Y.Map<number>>).get(0).get('counter')
).toBe(1);
});
test('with y text', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const inner = new Y.Map();
map.set('inner', inner);
const text = new Y.Text('hello');
inner.set('text', text);
const proxy = createYProxy<{ inner: { text: Text } }>(map);
proxy.inner = { ...proxy.inner };
expect(proxy.inner.text.yText).toBeInstanceOf(Y.Text);
expect(proxy.inner.text.yText.toJSON()).toBe('hello');
});
test('with native wrapper', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const inner = new Y.Map();
map.set('inner', inner);
const native = new Boxed(['hello', 'world']);
inner.set('native', native.yMap);
const proxy = createYProxy<{
inner: {
native: Boxed<string[]>;
native2: Boxed<number>;
};
}>(map);
expect(proxy.inner.native.getValue()).toEqual(['hello', 'world']);
proxy.inner.native.setValue(['hello', 'world', 'foo']);
expect(native.getValue()).toEqual(['hello', 'world', 'foo']);
// @ts-expect-error FIXME: ts error
expect(map.get('inner').get('native').get('value')).toEqual([
'hello',
'world',
'foo',
]);
const native2 = new Boxed(0);
proxy.inner.native2 = native2;
// @ts-expect-error FIXME: ts error
expect(map.get('inner').get('native2').get('value')).toBe(0);
native2.setValue(1);
// @ts-expect-error FIXME: ts error
expect(map.get('inner').get('native2').get('value')).toBe(1);
});
});
describe('stash and pop', () => {
test('object', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
map.set('num', 0);
const proxy = createYProxy<Record<string, any>>(map);
expect(proxy.num).toBe(0);
stashProp(map, 'num');
proxy.num = 1;
expect(proxy.num).toBe(1);
expect(map.get('num')).toBe(0);
proxy.num = 2;
popProp(map, 'num');
expect(map.get('num')).toBe(2);
});
test('array', () => {
const ydoc = new Y.Doc();
const arr = ydoc.getArray('arr');
arr.push([0]);
const proxy = createYProxy<Record<string, any>>(arr);
expect(proxy[0]).toBe(0);
stashProp(arr, 0);
proxy[0] = 1;
expect(proxy[0]).toBe(1);
expect(arr.get(0)).toBe(0);
popProp(arr, 0);
expect(arr.get(0)).toBe(1);
});
test('nested', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const arr = new Y.Array();
map.set('arr', arr);
arr.push([0]);
const proxy = createYProxy<Record<string, any>>(map);
expect(proxy.arr[0]).toBe(0);
stashProp(arr, 0);
proxy.arr[0] = 1;
expect(proxy.arr[0]).toBe(1);
expect(arr.get(0)).toBe(0);
popProp(arr, 0);
expect(arr.get(0)).toBe(1);
});
});
});
@@ -0,0 +1,159 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { sha } from '@blocksuite/global/utils';
/**
* @internal just for test
*/
export class MemoryBlobCRUD {
private readonly _map = new Map<string, Blob>();
delete(key: string) {
this._map.delete(key);
}
get(key: string) {
return this._map.get(key) ?? null;
}
list() {
return Array.from(this._map.keys());
}
async set(value: Blob): Promise<string>;
async set(key: string, value: Blob): Promise<string>;
async set(valueOrKey: string | Blob, _value?: Blob) {
const key =
typeof valueOrKey === 'string'
? valueOrKey
: await sha(await valueOrKey.arrayBuffer());
const value = typeof valueOrKey === 'string' ? _value : valueOrKey;
if (!value) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
'value is required'
);
}
this._map.set(key, value);
return key;
}
}
export const mimeExtMap = new Map([
['application/epub+zip', 'epub'],
['application/gzip', 'gz'],
['application/java-archive', 'jar'],
['application/json', 'json'],
['application/ld+json', 'jsonld'],
['application/msword', 'doc'],
['application/octet-stream', 'bin'],
['application/ogg', 'ogx'],
['application/pdf', 'pdf'],
['application/rtf', 'rtf'],
['application/vnd.amazon.ebook', 'azw'],
['application/vnd.apple.installer+xml', 'mpkg'],
['application/vnd.mozilla.xul+xml', 'xul'],
['application/vnd.ms-excel', 'xls'],
['application/vnd.ms-fontobject', 'eot'],
['application/vnd.ms-powerpoint', 'ppt'],
['application/vnd.oasis.opendocument.presentation', 'odp'],
['application/vnd.oasis.opendocument.spreadsheet', 'ods'],
['application/vnd.oasis.opendocument.text', 'odt'],
[
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'pptx',
],
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'],
[
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'docx',
],
['application/vnd.rar', 'rar'],
['application/vnd.visio', 'vsd'],
['application/x-7z-compressed', '7z'],
['application/x-abiword', 'abw'],
['application/x-bzip', 'bz'],
['application/x-bzip2', 'bz2'],
['application/x-cdf', 'cda'],
['application/x-csh', 'csh'],
['application/x-freearc', 'arc'],
['application/x-httpd-php', 'php'],
['application/x-sh', 'sh'],
['application/x-tar', 'tar'],
['application/xhtml+xml', 'xhtml'],
['application/xml', 'xml'],
['application/zip', 'zip'],
['application/zstd', 'zst'],
['audio/3gpp', '3gp'],
['audio/3gpp2', '3g2'],
['audio/aac', 'aac'],
['audio/midi', 'mid'],
['audio/mpeg', 'mp3'],
['audio/ogg', 'oga'],
['audio/opus', 'opus'],
['audio/wav', 'wav'],
['audio/webm', 'weba'],
['audio/x-midi', 'midi'],
['font/otf', 'otf'],
['font/ttf', 'ttf'],
['font/woff', 'woff'],
['font/woff2', 'woff2'],
['image/apng', 'apng'],
['image/avif', 'avif'],
['image/bmp', 'bmp'],
['image/gif', 'gif'],
['image/jpeg', 'jpeg'],
['image/png', 'png'],
['image/svg+xml', 'svg'],
['image/tiff', 'tiff'],
['image/vnd.microsoft.icon', 'ico'],
['image/webp', 'webp'],
['text/calendar', 'ics'],
['text/css', 'css'],
['text/csv', 'csv'],
['text/html', 'html'],
['text/javascript', 'js'],
['text/plain', 'txt'],
['text/xml', 'xml'],
['video/3gpp', '3gp'],
['video/3gpp2', '3g2'],
['video/mp2t', 'ts'],
['video/mp4', 'mp4'],
['video/mpeg', 'mpeg'],
['video/ogg', 'ogv'],
['video/webm', 'webm'],
['video/x-msvideo', 'avi'],
]);
export const extMimeMap = new Map(
Array.from(mimeExtMap.entries()).map(([mime, ext]) => [ext, mime])
);
const getExt = (type: string) => {
if (type === '') return 'blob';
const ext = mimeExtMap.get(type);
if (ext) return ext;
const guessExt = type.split('/');
return guessExt.at(-1) ?? 'blob';
};
export function getAssetName(assets: Map<string, Blob>, blobId: string) {
const blob = assets.get(blobId);
if (!blob) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
`blob not found for blobId: ${blobId}`
);
}
const name =
'name' in blob && typeof blob.name === 'string' ? blob.name : undefined;
if (name) {
if (name.includes('.')) return name;
return `${name}.${getExt(blob.type)}`;
}
return `${blobId}.${getExt(blob.type)}`;
}
@@ -0,0 +1,310 @@
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import type { Doc } from '../store/index.js';
import type { AssetsManager } from '../transformer/assets.js';
import type { DraftModel, Job, Slice } from '../transformer/index.js';
import type {
BlockSnapshot,
DocSnapshot,
SliceSnapshot,
} from '../transformer/type.js';
import { ASTWalkerContext } from './context.js';
export type FromDocSnapshotPayload = {
snapshot: DocSnapshot;
assets?: AssetsManager;
};
export type FromBlockSnapshotPayload = {
snapshot: BlockSnapshot;
assets?: AssetsManager;
};
export type FromSliceSnapshotPayload = {
snapshot: SliceSnapshot;
assets?: AssetsManager;
};
export type FromDocSnapshotResult<Target> = {
file: Target;
assetsIds: string[];
};
export type FromBlockSnapshotResult<Target> = {
file: Target;
assetsIds: string[];
};
export type FromSliceSnapshotResult<Target> = {
file: Target;
assetsIds: string[];
};
export type ToDocSnapshotPayload<Target> = {
file: Target;
assets?: AssetsManager;
};
export type ToBlockSnapshotPayload<Target> = {
file: Target;
assets?: AssetsManager;
};
export type ToSliceSnapshotPayload<Target> = {
file: Target;
assets?: AssetsManager;
};
export function wrapFakeNote(snapshot: SliceSnapshot) {
if (snapshot.content[0]?.flavour !== 'affine:note') {
snapshot.content = [
{
type: 'block',
id: '',
flavour: 'affine:note',
props: {},
children: snapshot.content,
},
];
}
}
export abstract class BaseAdapter<AdapterTarget = unknown> {
job: Job;
get configs() {
return this.job.adapterConfigs;
}
constructor(job: Job) {
this.job = job;
}
async fromBlock(model: DraftModel) {
try {
const blockSnapshot = this.job.blockToSnapshot(model);
if (!blockSnapshot) return;
return await this.fromBlockSnapshot({
snapshot: blockSnapshot,
assets: this.job.assetsManager,
});
} catch (error) {
console.error('Cannot convert block to snapshot');
console.error(error);
return;
}
}
abstract fromBlockSnapshot(
payload: FromBlockSnapshotPayload
):
| Promise<FromBlockSnapshotResult<AdapterTarget>>
| FromBlockSnapshotResult<AdapterTarget>;
async fromDoc(doc: Doc) {
try {
const docSnapshot = this.job.docToSnapshot(doc);
if (!docSnapshot) return;
return await this.fromDocSnapshot({
snapshot: docSnapshot,
assets: this.job.assetsManager,
});
} catch (error) {
console.error('Cannot convert doc to snapshot');
console.error(error);
return;
}
}
abstract fromDocSnapshot(
payload: FromDocSnapshotPayload
):
| Promise<FromDocSnapshotResult<AdapterTarget>>
| FromDocSnapshotResult<AdapterTarget>;
async fromSlice(slice: Slice) {
try {
const sliceSnapshot = this.job.sliceToSnapshot(slice);
if (!sliceSnapshot) return;
wrapFakeNote(sliceSnapshot);
return await this.fromSliceSnapshot({
snapshot: sliceSnapshot,
assets: this.job.assetsManager,
});
} catch (error) {
console.error('Cannot convert slice to snapshot');
console.error(error);
return;
}
}
abstract fromSliceSnapshot(
payload: FromSliceSnapshotPayload
):
| Promise<FromSliceSnapshotResult<AdapterTarget>>
| FromSliceSnapshotResult<AdapterTarget>;
async toBlock(
payload: ToBlockSnapshotPayload<AdapterTarget>,
doc: Doc,
parent?: string,
index?: number
) {
try {
const snapshot = await this.toBlockSnapshot(payload);
if (!snapshot) return;
return await this.job.snapshotToBlock(snapshot, doc, parent, index);
} catch (error) {
console.error('Cannot convert block snapshot to block');
console.error(error);
return;
}
}
abstract toBlockSnapshot(
payload: ToBlockSnapshotPayload<AdapterTarget>
): Promise<BlockSnapshot> | BlockSnapshot;
async toDoc(payload: ToDocSnapshotPayload<AdapterTarget>) {
try {
const snapshot = await this.toDocSnapshot(payload);
if (!snapshot) return;
return await this.job.snapshotToDoc(snapshot);
} catch (error) {
console.error('Cannot convert doc snapshot to doc');
console.error(error);
return;
}
}
abstract toDocSnapshot(
payload: ToDocSnapshotPayload<AdapterTarget>
): Promise<DocSnapshot> | DocSnapshot;
async toSlice(
payload: ToSliceSnapshotPayload<AdapterTarget>,
doc: Doc,
parent?: string,
index?: number
) {
try {
const snapshot = await this.toSliceSnapshot(payload);
if (!snapshot) return;
return await this.job.snapshotToSlice(snapshot, doc, parent, index);
} catch (error) {
console.error('Cannot convert slice snapshot to slice');
console.error(error);
return;
}
}
abstract toSliceSnapshot(
payload: ToSliceSnapshotPayload<AdapterTarget>
): Promise<SliceSnapshot | null> | SliceSnapshot | null;
}
type Keyof<T> = T extends unknown ? keyof T : never;
type WalkerFn<ONode extends object, TNode extends object> = (
o: NodeProps<ONode>,
context: ASTWalkerContext<TNode>
) => Promise<void> | void;
export type NodeProps<Node extends object> = {
node: Node;
next?: Node | null;
parent: NodeProps<Node> | null;
prop: Keyof<Node> | null;
index: number | null;
};
// Ported from https://github.com/Rich-Harris/estree-walker MIT License
export class ASTWalker<ONode extends object, TNode extends object | never> {
private _enter: WalkerFn<ONode, TNode> | undefined;
private _isONode!: (node: unknown) => node is ONode;
private _leave: WalkerFn<ONode, TNode> | undefined;
private _visit = async (o: NodeProps<ONode>) => {
if (!o.node) return;
this.context._skipChildrenNum = 0;
this.context._skip = false;
if (this._enter) {
await this._enter(o, this.context);
}
if (this.context._skip) {
return;
}
for (const key in o.node) {
const value = o.node[key];
if (value && typeof value === 'object') {
if (Array.isArray(value)) {
for (
let i = this.context._skipChildrenNum;
i < value.length;
i += 1
) {
const item = value[i];
if (
item !== null &&
typeof item === 'object' &&
this._isONode(item)
) {
const nextItem = value[i + 1] ?? null;
await this._visit({
node: item,
next: nextItem,
parent: o,
prop: key as unknown as Keyof<ONode>,
index: i,
});
}
}
} else if (
this.context._skipChildrenNum === 0 &&
this._isONode(value)
) {
await this._visit({
node: value,
next: null,
parent: o,
prop: key as unknown as Keyof<ONode>,
index: null,
});
}
}
}
if (this._leave) {
await this._leave(o, this.context);
}
};
private context: ASTWalkerContext<TNode>;
setEnter = (fn: WalkerFn<ONode, TNode>) => {
this._enter = fn;
};
setLeave = (fn: WalkerFn<ONode, TNode>) => {
this._leave = fn;
};
setONodeTypeGuard = (fn: (node: unknown) => node is ONode) => {
this._isONode = fn;
};
walk = async (oNode: ONode, tNode: TNode) => {
this.context.openNode(tNode);
await this._visit({ node: oNode, parent: null, prop: null, index: null });
if (this.context.stack.length !== 1) {
throw new BlockSuiteError(1, 'There are unclosed nodes');
}
return this.context.currentNode();
};
walkONode = async (oNode: ONode) => {
await this._visit({ node: oNode, parent: null, prop: null, index: null });
};
constructor() {
this.context = new ASTWalkerContext<TNode>();
}
}
@@ -0,0 +1,119 @@
type Keyof<T> = T extends unknown ? keyof T : never;
export class ASTWalkerContext<TNode extends object> {
private _defaultProp: Keyof<TNode> = 'children' as unknown as Keyof<TNode>;
private _globalContext: Record<string, unknown> = Object.create(null);
private _stack: {
node: TNode;
prop: Keyof<TNode>;
context: Record<string, unknown>;
}[] = [];
_skip = false;
_skipChildrenNum = 0;
setDefaultProp = (parentProp: Keyof<TNode>) => {
this._defaultProp = parentProp;
};
get stack() {
return this._stack;
}
private current() {
return this._stack[this._stack.length - 1];
}
cleanGlobalContextStack(key: string) {
if (this._globalContext[key] instanceof Array) {
this._globalContext[key] = [];
}
}
closeNode() {
const ele = this._stack.pop();
if (!ele) return this;
const parent = this._stack.pop();
if (!parent) {
this._stack.push(ele);
return this;
}
if (parent.node[ele.prop] instanceof Array) {
(parent.node[ele.prop] as Array<object>).push(ele.node);
}
this._stack.push(parent);
return this;
}
currentNode() {
return this.current()?.node;
}
getGlobalContext(key: string) {
return this._globalContext[key];
}
getGlobalContextStack<StackElement>(key: string) {
const stack = this._globalContext[key];
if (stack instanceof Array) {
return stack as StackElement[];
} else {
return [] as StackElement[];
}
}
getNodeContext(key: string) {
return this.current().context[key];
}
getPreviousNodeContext(key: string) {
return this._stack[this._stack.length - 2]?.context[key];
}
openNode(node: TNode, parentProp?: Keyof<TNode>) {
this._stack.push({
node,
prop: parentProp ?? this._defaultProp,
context: Object.create(null),
});
return this;
}
previousNode() {
return this._stack[this._stack.length - 2]?.node;
}
pushGlobalContextStack<StackElement>(key: string, value: StackElement) {
const stack = this._globalContext[key];
if (stack instanceof Array) {
stack.push(value);
} else {
this._globalContext[key] = [value];
}
}
setGlobalContext(key: string, value: unknown) {
this._globalContext[key] = value;
return this;
}
setGlobalContextStack<StackElement>(key: string, value: StackElement[]) {
this._globalContext[key] = value;
}
setNodeContext(key: string, value: unknown) {
this._stack[this._stack.length - 1].context[key] = value;
return this;
}
skipAllChildren() {
this._skip = true;
}
skipChildren(num = 1) {
this._skipChildrenNum = num;
}
}
@@ -0,0 +1,3 @@
export * from './assets.js';
export * from './base.js';
export * from './context.js';
+11
View File
@@ -0,0 +1,11 @@
export const COLLECTION_VERSION = 2;
export const PAGE_VERSION = 2;
export const SCHEMA_NOT_FOUND_MESSAGE =
'Schema not found. The block flavour may not be registered.';
export const TEXT_UNIQ_IDENTIFIER = '$blocksuite:internal:text$';
export const NATIVE_UNIQ_IDENTIFIER = '$blocksuite:internal:native$';
export const SYS_KEYS = new Set(['id', 'flavour', 'children']);
+47
View File
@@ -0,0 +1,47 @@
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path="../shim.d.ts" />
export type { Y };
export * from './adapter/index.js';
export * from './reactive/index.js';
export * from './schema/index.js';
export * from './store/index.js';
export * from './transformer/index.js';
export {
createAutoIncrementIdGenerator,
createAutoIncrementIdGeneratorByClientId,
type IdGenerator,
nanoid,
uuidv4,
} from './utils/id-generator.js';
export * as Utils from './utils/utils.js';
export * from './yjs/index.js';
export { Slot } from '@blocksuite/global/utils';
import type * as Y from 'yjs';
const env =
typeof globalThis !== 'undefined'
? globalThis
: typeof window !== 'undefined'
? window
: // oxlint-disable-next-line
// @ts-ignore FIXME: typecheck error
typeof global !== 'undefined'
? // oxlint-disable-next-line
// @ts-ignore FIXME: typecheck error
global
: {};
const importIdentifier = '__ $BLOCKSUITE_STORE$ __';
// oxlint-disable-next-line
// @ts-ignore FIXME: typecheck error
if (env[importIdentifier] === true) {
// https://github.com/yjs/yjs/issues/438
console.error(
'@blocksuite/store was already imported. This breaks constructor checks and will lead to issues!'
);
}
// oxlint-disable-next-line
// @ts-ignore FIXME: typecheck error
env[importIdentifier] = true;
@@ -0,0 +1,55 @@
import * as Y from 'yjs';
import { NATIVE_UNIQ_IDENTIFIER } from '../consts.js';
export type OnBoxedChange = (data: unknown) => void;
export class Boxed<T = unknown> {
static from = <T>(map: Y.Map<T>, onChange?: OnBoxedChange): Boxed<T> => {
return new Boxed<T>(map.get('value') as T, onChange);
};
static is = (value: unknown): value is Boxed => {
return (
value instanceof Y.Map && value.get('type') === NATIVE_UNIQ_IDENTIFIER
);
};
private readonly _map: Y.Map<T>;
private _onChange?: OnBoxedChange;
getValue = () => {
return this._map.get('value');
};
setValue = (value: T) => {
return this._map.set('value', value);
};
get yMap() {
return this._map;
}
constructor(value: T, onChange?: OnBoxedChange) {
this._onChange = onChange;
if (
value instanceof Y.Map &&
value.doc &&
value.get('type') === NATIVE_UNIQ_IDENTIFIER
) {
this._map = value;
} else {
this._map = new Y.Map();
this._map.set('type', NATIVE_UNIQ_IDENTIFIER as T);
this._map.set('value', value);
}
this._map.observeDeep(() => {
this._onChange?.(this.getValue());
});
}
bind(onChange: OnBoxedChange) {
this._onChange = onChange;
}
}
@@ -0,0 +1,4 @@
export * from './boxed.js';
export * from './proxy.js';
export * from './text.js';
export * from './utils.js';
@@ -0,0 +1,357 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { YArrayEvent, YMapEvent } from 'yjs';
import { Array as YArray, Map as YMap } from 'yjs';
import { Boxed, type OnBoxedChange } from './boxed.js';
import { type OnTextChange, Text } from './text.js';
import type { UnRecord } from './utils.js';
import { BaseReactiveYData, native2Y, y2Native } from './utils.js';
export type ProxyOptions<T> = {
onChange?: (data: T) => void;
};
const proxies = new WeakMap<any, BaseReactiveYData<any, any>>();
export class ReactiveYArray extends BaseReactiveYData<
unknown[],
YArray<unknown>
> {
private _observer = (event: YArrayEvent<unknown>) => {
this._onObserve(event, () => {
let retain = 0;
event.changes.delta.forEach(change => {
if (change.retain) {
retain += change.retain;
return;
}
if (change.delete) {
this._updateWithSkip(() => {
this._source.splice(retain, change.delete);
});
return;
}
if (change.insert) {
const _arr = [change.insert].flat();
const proxyList = _arr.map(value => createYProxy(value));
this._updateWithSkip(() => {
this._source.splice(retain, 0, ...proxyList);
});
retain += change.insert.length;
}
});
});
};
protected _getProxy = () => {
return new Proxy(this._source, {
has: (target, p) => {
return Reflect.has(target, p);
},
set: (target, p, value, receiver) => {
if (typeof p !== 'string') {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'key cannot be a symbol'
);
}
const index = Number(p);
if (this._skipNext || Number.isNaN(index)) {
return Reflect.set(target, p, value, receiver);
}
if (this._stashed.has(index)) {
const result = Reflect.set(target, p, value, receiver);
this._options.onChange?.(this._proxy);
return result;
}
const reactive = proxies.get(this._ySource);
if (!reactive) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not subscribed before changes'
);
}
const doc = this._ySource.doc;
if (!doc) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not bound to a Y.Doc'
);
}
const yData = native2Y(value);
this._transact(doc, () => {
if (index < this._ySource.length) {
this._ySource.delete(index, 1);
}
this._ySource.insert(index, [yData]);
});
const data = createYProxy(yData, this._options);
return Reflect.set(target, p, data, receiver);
},
get: (target, p, receiver) => {
return Reflect.get(target, p, receiver);
},
deleteProperty: (target, p): boolean => {
if (typeof p !== 'string') {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'key cannot be a symbol'
);
}
const proxied = proxies.get(this._ySource);
if (!proxied) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not subscribed before changes'
);
}
const doc = this._ySource.doc;
if (!doc) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not bound to a Y.Doc'
);
}
const index = Number(p);
if (this._skipNext || Number.isNaN(index)) {
return Reflect.deleteProperty(target, p);
}
this._transact(doc, () => {
this._ySource.delete(index, 1);
});
return Reflect.deleteProperty(target, p);
},
});
};
protected readonly _proxy: unknown[];
constructor(
protected readonly _source: unknown[],
protected readonly _ySource: YArray<unknown>,
protected readonly _options: ProxyOptions<unknown[]>
) {
super();
this._proxy = this._getProxy();
proxies.set(_ySource, this);
_ySource.observe(this._observer);
}
pop(prop: number) {
const value = this._source[prop];
this._stashed.delete(prop);
this._proxy[prop] = value;
}
stash(prop: number) {
this._stashed.add(prop);
}
}
export class ReactiveYMap extends BaseReactiveYData<UnRecord, YMap<unknown>> {
private _observer = (event: YMapEvent<unknown>) => {
this._onObserve(event, () => {
event.keysChanged.forEach(key => {
const type = event.changes.keys.get(key);
if (!type) {
return;
}
if (type.action === 'delete') {
this._updateWithSkip(() => {
delete this._source[key];
});
} else if (type.action === 'add' || type.action === 'update') {
const current = this._ySource.get(key);
this._updateWithSkip(() => {
this._source[key] = proxies.has(current)
? proxies.get(current)
: createYProxy(current, this._options);
});
}
});
});
};
protected _getProxy = () => {
return new Proxy(this._source, {
has: (target, p) => {
return Reflect.has(target, p);
},
set: (target, p, value, receiver) => {
if (typeof p !== 'string') {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'key cannot be a symbol'
);
}
if (this._skipNext) {
return Reflect.set(target, p, value, receiver);
}
if (this._stashed.has(p)) {
const result = Reflect.set(target, p, value, receiver);
this._options.onChange?.(this._proxy);
return result;
}
const reactive = proxies.get(this._ySource);
if (!reactive) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not subscribed before changes'
);
}
const doc = this._ySource.doc;
if (!doc) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not bound to a Y.Doc'
);
}
const yData = native2Y(value);
this._transact(doc, () => {
this._ySource.set(p, yData);
});
const data = createYProxy(yData, this._options);
return Reflect.set(target, p, data, receiver);
},
get: (target, p, receiver) => {
return Reflect.get(target, p, receiver);
},
deleteProperty: (target, p) => {
if (typeof p !== 'string') {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'key cannot be a symbol'
);
}
if (this._skipNext) {
return Reflect.deleteProperty(target, p);
}
const proxied = proxies.get(this._ySource);
if (!proxied) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not subscribed before changes'
);
}
const doc = this._ySource.doc;
if (!doc) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not bound to a Y.Doc'
);
}
this._transact(doc, () => {
this._ySource.delete(p);
});
return Reflect.deleteProperty(target, p);
},
});
};
protected readonly _proxy: UnRecord;
// eslint-disable-next-line sonarjs/no-identical-functions
constructor(
protected readonly _source: UnRecord,
protected readonly _ySource: YMap<unknown>,
protected readonly _options: ProxyOptions<UnRecord>
) {
super();
this._proxy = this._getProxy();
proxies.set(_ySource, this);
_ySource.observe(this._observer);
}
// eslint-disable-next-line sonarjs/no-identical-functions
pop(prop: string) {
const value = this._source[prop];
this._stashed.delete(prop);
this._proxy[prop] = value;
}
stash(prop: string) {
this._stashed.add(prop);
}
}
export function createYProxy<Data>(
yAbstract: unknown,
options: ProxyOptions<Data> = {}
): Data {
if (proxies.has(yAbstract)) {
return proxies.get(yAbstract)!.proxy as Data;
}
return y2Native(yAbstract, {
transform: (value, origin) => {
if (value instanceof Text) {
value.bind(options.onChange as OnTextChange);
return value;
}
if (Boxed.is(origin)) {
(value as Boxed).bind(options.onChange as OnBoxedChange);
return value;
}
if (origin instanceof YArray) {
const data = new ReactiveYArray(
value as unknown[],
origin,
options as ProxyOptions<unknown[]>
);
return data.proxy;
}
if (origin instanceof YMap) {
const data = new ReactiveYMap(
value as UnRecord,
origin,
options as ProxyOptions<UnRecord>
);
return data.proxy;
}
return value;
},
}) as Data;
}
export function stashProp(yMap: YMap<unknown>, prop: string): void;
export function stashProp(yMap: YArray<unknown>, prop: number): void;
export function stashProp(yAbstract: unknown, prop: string | number) {
const proxy = proxies.get(yAbstract);
if (!proxy) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not subscribed before changes'
);
}
proxy.stash(prop);
}
export function popProp(yMap: YMap<unknown>, prop: string): void;
export function popProp(yMap: YArray<unknown>, prop: number): void;
export function popProp(yAbstract: unknown, prop: string | number) {
const proxy = proxies.get(yAbstract);
if (!proxy) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not subscribed before changes'
);
}
proxy.pop(prop);
}
@@ -0,0 +1,336 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/inline';
import { type Signal, signal } from '@preact/signals-core';
import * as Y from 'yjs';
export interface OptionalAttributes {
attributes?: Record<string, any>;
}
export type DeltaOperation = {
insert?: string;
delete?: number;
retain?: number;
} & OptionalAttributes;
export type OnTextChange = (data: Y.Text) => void;
export class Text {
private _deltas$: Signal<DeltaOperation[]>;
private _length$: Signal<number>;
private _onChange?: OnTextChange;
private readonly _yText: Y.Text;
get deltas$() {
return this._deltas$;
}
get length() {
return this._length$.value;
}
get yText() {
return this._yText;
}
constructor(
input?: Y.Text | string | DeltaInsert[],
onChange?: OnTextChange
) {
this._onChange = onChange;
let length = 0;
if (typeof input === 'string') {
const text = input.replaceAll('\r\n', '\n');
length = text.length;
this._yText = new Y.Text(text);
} else if (input instanceof Y.Text) {
this._yText = input;
if (input.doc) {
length = input.length;
}
} else if (input instanceof Array) {
for (const delta of input) {
if (delta.insert) {
delta.insert = delta.insert.replaceAll('\r\n', '\n');
length += delta.insert.length;
}
}
const yText = new Y.Text();
yText.applyDelta(input);
this._yText = yText;
} else {
this._yText = new Y.Text();
}
this._length$ = signal(length);
this._deltas$ = signal(this._yText.doc ? this._yText.toDelta() : []);
this._yText.observe(() => {
this._length$.value = this._yText.length;
this._deltas$.value = this._yText.toDelta();
this._onChange?.(this._yText);
});
}
private _transact(callback: () => void) {
const doc = this._yText.doc;
if (!doc) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'Failed to transact text! yText is not attached to a doc'
);
}
doc.transact(() => {
callback();
}, doc.clientID);
}
applyDelta(delta: DeltaOperation[]) {
this._transact(() => {
this._yText?.applyDelta(delta);
});
}
bind(onChange?: OnTextChange) {
this._onChange = onChange;
}
clear() {
if (!this._yText.length) {
return;
}
this._transact(() => {
this._yText.delete(0, this._yText.length);
});
}
clone() {
return new Text(this._yText.clone(), this._onChange);
}
delete(index: number, length: number) {
if (length === 0) {
return;
}
if (index < 0 || length < 0 || index + length > this._yText.length) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'Failed to delete text! Index or length out of range, index: ' +
index +
', length: ' +
length +
', text length: ' +
this._yText.length
);
}
this._transact(() => {
this._yText.delete(index, length);
});
}
format(index: number, length: number, format: any) {
if (length === 0) {
return;
}
if (index < 0 || length < 0 || index + length > this._yText.length) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'Failed to format text! Index or length out of range, index: ' +
index +
', length: ' +
length +
', text length: ' +
this._yText.length
);
}
this._transact(() => {
this._yText.format(index, length, format);
});
}
insert(content: string, index: number, attributes?: Record<string, unknown>) {
if (!content.length) {
return;
}
if (index < 0 || index > this._yText.length) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'Failed to insert text! Index or length out of range, index: ' +
index +
', length: ' +
length +
', text length: ' +
this._yText.length
);
}
this._transact(() => {
this._yText.insert(index, content, attributes);
});
}
join(other: Text) {
if (!other || !other.toDelta().length) {
return;
}
this._transact(() => {
const yOther = other._yText;
const delta: DeltaOperation[] = yOther.toDelta();
delta.unshift({ retain: this._yText.length });
this._yText.applyDelta(delta);
});
}
replace(
index: number,
length: number,
content: string,
attributes?: BaseTextAttributes
) {
if (index < 0 || length < 0 || index + length > this._yText.length) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'Failed to replace text! The length of the text is' +
this._yText.length +
', but you are trying to replace from' +
index +
'to' +
index +
length
);
}
this._transact(() => {
this._yText.delete(index, length);
this._yText.insert(index, content, attributes);
});
}
sliceToDelta(begin: number, end?: number): DeltaOperation[] {
const result: DeltaOperation[] = [];
if (end && begin >= end) {
return result;
}
if (begin === 0 && end === 0) {
return [];
}
const delta = this.toDelta();
if (begin < 1 && !end) {
return delta;
}
if (delta && delta instanceof Array) {
let charNum = 0;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < delta.length; i++) {
const content = delta[i];
let contentText: string = content.insert || '';
const contentLen = contentText.length;
const isLastOp = end && charNum + contentLen > end;
const isFirstOp = charNum + contentLen > begin && result.length === 0;
if (isFirstOp && isLastOp) {
contentText = contentText.slice(begin - charNum, end - charNum);
result.push({
...content,
insert: contentText,
});
break;
} else if (isFirstOp || isLastOp) {
contentText = isLastOp
? contentText.slice(0, end - charNum)
: contentText.slice(begin - charNum);
result.push({
...content,
insert: contentText,
});
} else {
result.length > 0 && result.push(content);
}
if (end && charNum + contentLen > end) {
break;
}
charNum = charNum + contentLen;
}
}
return result;
}
/**
* NOTE: The string included in [index, index + length) will be deleted.
*
* Here are three cases for point position(index + length):
* [{insert: 'abc', ...}, {insert: 'def', ...}, {insert: 'ghi', ...}]
* 1. abc|de|fghi
* left: [{insert: 'abc', ...}]
* right: [{insert: 'f', ...}, {insert: 'ghi', ...}]
* 2. abc|def|ghi
* left: [{insert: 'abc', ...}]
* right: [{insert: 'ghi', ...}]
* 3. abc|defg|hi
* left: [{insert: 'abc', ...}]
* right: [{insert: 'hi', ...}]
*/
split(index: number, length = 0): Text {
if (index < 0 || length < 0 || index + length > this._yText.length) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'Failed to split text! Index or length out of range, index: ' +
index +
', length: ' +
length +
', text length: ' +
this._yText.length
);
}
const deltas = this._yText.toDelta();
if (!(deltas instanceof Array)) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'This text cannot be split because we failed to get the deltas of it.'
);
}
let tmpIndex = 0;
const rightDeltas: DeltaInsert[] = [];
for (let i = 0; i < deltas.length; i++) {
const insert = deltas[i].insert;
if (typeof insert === 'string') {
if (tmpIndex + insert.length >= index + length) {
const insertRight = insert.slice(index + length - tmpIndex);
rightDeltas.push({
insert: insertRight,
attributes: deltas[i].attributes,
});
rightDeltas.push(...deltas.slice(i + 1));
break;
}
tmpIndex += insert.length;
} else {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'This text cannot be split because it contains non-string insert.'
);
}
}
this.delete(index, this.length - index);
const rightYText = new Y.Text();
rightYText.applyDelta(rightDeltas);
const rightText = new Text(rightYText, this._onChange);
return rightText;
}
toDelta(): DeltaOperation[] {
return this._yText?.toDelta() || [];
}
toString() {
return this._yText?.toString() || '';
}
}
@@ -0,0 +1,157 @@
import type { Doc as YDoc, YEvent } from 'yjs';
import { Array as YArray, Map as YMap, Text as YText, UndoManager } from 'yjs';
import { Boxed } from './boxed.js';
import type { ProxyOptions } from './proxy.js';
import { Text } from './text.js';
export type Native2Y<T> =
T extends Record<string, infer U>
? YMap<U>
: T extends Array<infer U>
? YArray<U>
: T;
export function isPureObject(value: unknown): value is object {
return (
value !== null &&
typeof value === 'object' &&
Object.prototype.toString.call(value) === '[object Object]' &&
[Object, undefined, null].some(x => x === value.constructor)
);
}
type TransformOptions = {
deep?: boolean;
transform?: (value: unknown, origin: unknown) => unknown;
};
export function native2Y<T>(
value: T,
{ deep = true, transform = x => x }: TransformOptions = {}
): Native2Y<T> {
if (value instanceof Boxed) {
return value.yMap as Native2Y<T>;
}
if (value instanceof Text) {
if (value.yText.doc) {
return value.yText.clone() as Native2Y<T>;
}
return value.yText as Native2Y<T>;
}
if (Array.isArray(value)) {
const yArray: YArray<unknown> = new YArray<unknown>();
const result = value.map(item => {
return deep ? native2Y(item, { deep, transform }) : item;
});
yArray.insert(0, result);
return yArray as Native2Y<T>;
}
if (isPureObject(value)) {
const yMap = new YMap<unknown>();
Object.entries(value).forEach(([key, value]) => {
yMap.set(key, deep ? native2Y(value, { deep, transform }) : value);
});
return yMap as Native2Y<T>;
}
return value as Native2Y<T>;
}
export function y2Native(
yAbstract: unknown,
{ deep = true, transform = x => x }: TransformOptions = {}
) {
if (Boxed.is(yAbstract)) {
const data = new Boxed(yAbstract);
return transform(data, yAbstract);
}
if (yAbstract instanceof YText) {
const data = new Text(yAbstract);
return transform(data, yAbstract);
}
if (yAbstract instanceof YArray) {
const data: unknown[] = yAbstract
.toArray()
.map(item => (deep ? y2Native(item, { deep, transform }) : item));
return transform(data, yAbstract);
}
if (yAbstract instanceof YMap) {
const data: Record<string, unknown> = Object.fromEntries(
Array.from(yAbstract.entries()).map(([key, value]) => {
return [key, deep ? y2Native(value, { deep, transform }) : value] as [
string,
unknown,
];
})
);
return transform(data, yAbstract);
}
return transform(yAbstract, yAbstract);
}
export type UnRecord = Record<string, unknown>;
export abstract class BaseReactiveYData<T, Y> {
protected _getOrigin = (
doc: YDoc
): {
doc: YDoc;
proxy: true;
target: BaseReactiveYData<any, any>;
} => {
return {
doc,
proxy: true,
target: this,
};
};
protected _onObserve = (event: YEvent<any>, handler: () => void) => {
if (
event.transaction.origin?.proxy !== true &&
(!event.transaction.local ||
event.transaction.origin instanceof UndoManager)
) {
handler();
}
this._options.onChange?.(this._proxy);
};
protected abstract readonly _options: ProxyOptions<T>;
protected abstract readonly _proxy: T;
protected _skipNext = false;
protected abstract readonly _source: T;
protected readonly _stashed = new Set<string | number>();
protected _transact = (doc: YDoc, fn: () => void) => {
doc.transact(fn, this._getOrigin(doc));
};
protected _updateWithSkip = (fn: () => void) => {
this._skipNext = true;
fn();
this._skipNext = false;
};
protected abstract readonly _ySource: Y;
get proxy() {
return this._proxy;
}
protected abstract _getProxy(): T;
abstract pop(prop: string | number): void;
abstract stash(prop: string | number): void;
}
@@ -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();
}
}
@@ -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}`
);
}
}
@@ -0,0 +1,2 @@
export * from './base.js';
export { Schema } from './schema.js';
@@ -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}.`
);
}
}
}
@@ -0,0 +1,2 @@
export { test } from './test.js';
export { DocCollectionAddonType } from './type.js';
@@ -0,0 +1,19 @@
import type { DocCollection, DocCollectionOptions } from '../collection.js';
type DocCollectionConstructor<Keys extends string> = {
new (storeOptions: DocCollectionOptions): Omit<DocCollection, Keys>;
};
export type AddOn<Keys extends string> = (
originalClass: DocCollectionConstructor<Keys>,
context: ClassDecoratorContext
) => { new (storeOptions: DocCollectionOptions): unknown };
export type AddOnReturn<Keys extends string> = (
originalClass: DocCollectionConstructor<Keys>,
context: ClassDecoratorContext
) => typeof DocCollection;
export function addOnFactory<Keys extends string>(fn: AddOn<Keys>) {
return fn as AddOnReturn<Keys>;
}
@@ -0,0 +1,38 @@
import { assertExists } from '@blocksuite/global/utils';
import type { JSXElement } from '../../utils/jsx.js';
import { serializeYDoc, yDocToJSXNode } from '../../utils/jsx.js';
import { addOnFactory } from './shared.js';
export interface TestAddon {
importDocSnapshot: (json: unknown, docId: string) => Promise<void>;
exportJSX: (blockId?: string, docId?: string) => JSXElement;
}
export const test = addOnFactory<keyof TestAddon>(
originalClass =>
class extends originalClass {
/** @internal Only for testing */
exportJSX(blockId?: string, docId = this.meta.docMetas.at(0)?.id) {
assertExists(docId);
const doc = this.doc.spaces.get(docId);
assertExists(doc);
const docJson = serializeYDoc(doc);
if (!docJson) {
throw new Error(`Doc ${docId} doesn't exist`);
}
const blockJson = docJson.blocks as Record<string, unknown>;
if (!blockId) {
const rootId = Object.keys(blockJson).at(0);
if (!rootId) {
return null;
}
blockId = rootId;
}
if (!blockJson[blockId]) {
return null;
}
return yDocToJSXNode(blockJson, blockId);
}
}
);
@@ -0,0 +1,7 @@
import type { TestAddon } from './test.js';
export class DocCollectionAddonType implements TestAddon {
exportJSX!: TestAddon['exportJSX'];
importDocSnapshot!: TestAddon['importDocSnapshot'];
}
@@ -0,0 +1,306 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { BlockSuiteFlags } from '@blocksuite/global/types';
import { type Logger, NoopLogger, Slot } from '@blocksuite/global/utils';
import {
AwarenessEngine,
type AwarenessSource,
BlobEngine,
type BlobSource,
DocEngine,
type DocSource,
MemoryBlobSource,
NoopDocSource,
} from '@blocksuite/sync';
import clonedeep from 'lodash.clonedeep';
import merge from 'lodash.merge';
import { Awareness } from 'y-protocols/awareness.js';
import * as Y from 'yjs';
import type { Schema } from '../schema/index.js';
import type { IdGenerator } from '../utils/id-generator.js';
import {
AwarenessStore,
BlockSuiteDoc,
type RawAwarenessState,
} from '../yjs/index.js';
import { DocCollectionAddonType, test } from './addon/index.js';
import { BlockCollection, type GetDocOptions } from './doc/block-collection.js';
import type { Doc, Query } from './doc/index.js';
import type { IdGeneratorType } from './id.js';
import { pickIdGenerator } from './id.js';
import { DocCollectionMeta, type DocMeta } from './meta.js';
export type DocCollectionOptions = {
schema: Schema;
id?: string;
idGenerator?: IdGeneratorType | IdGenerator;
defaultFlags?: Partial<BlockSuiteFlags>;
logger?: Logger;
docSources?: {
main: DocSource;
shadows?: DocSource[];
};
blobSources?: {
main: BlobSource;
shadows?: BlobSource[];
};
awarenessSources?: AwarenessSource[];
};
const FLAGS_PRESET = {
enable_synced_doc_block: false,
enable_pie_menu: false,
enable_database_number_formatting: false,
enable_database_attachment_note: false,
enable_database_full_width: false,
enable_legacy_validation: true,
enable_block_query: false,
enable_lasso_tool: false,
enable_edgeless_text: true,
enable_ai_onboarding: false,
enable_ai_chat_block: false,
enable_color_picker: false,
enable_mind_map_import: false,
enable_advanced_block_visibility: false,
enable_shape_shadow_blur: false,
enable_new_dnd: true,
enable_mobile_keyboard_toolbar: false,
enable_mobile_linked_doc_menu: false,
readonly: {},
} satisfies BlockSuiteFlags;
export interface StackItem {
meta: Map<'cursor-location' | 'selection-state', unknown>;
}
// oxlint-disable-next-line
// @ts-ignore FIXME: typecheck error
@test
export class DocCollection extends DocCollectionAddonType {
static Y = Y;
protected readonly _schema: Schema;
readonly awarenessStore: AwarenessStore;
readonly awarenessSync: AwarenessEngine;
readonly blobSync: BlobEngine;
readonly blockCollections = new Map<string, BlockCollection>();
readonly doc: BlockSuiteDoc;
readonly docSync: DocEngine;
readonly id: string;
readonly idGenerator: IdGenerator;
meta: DocCollectionMeta;
slots = {
docAdded: new Slot<string>(),
docUpdated: new Slot(),
docRemoved: new Slot<string>(),
docCreated: new Slot<string>(),
};
get docs() {
return this.blockCollections;
}
get isEmpty() {
if (this.doc.store.clients.size === 0) return true;
let flag = false;
if (this.doc.store.clients.size === 1) {
const items = Array.from(this.doc.store.clients.values())[0];
// workspaceVersion and pageVersion were set when the collection is initialized
if (items.length <= 2) {
flag = true;
}
}
return flag;
}
get schema() {
return this._schema;
}
constructor({
id,
schema,
idGenerator,
defaultFlags,
awarenessSources = [],
docSources = {
main: new NoopDocSource(),
},
blobSources = {
main: new MemoryBlobSource(),
},
logger = new NoopLogger(),
}: DocCollectionOptions) {
super();
this._schema = schema;
this.id = id || '';
this.doc = new BlockSuiteDoc({ guid: id });
this.awarenessStore = new AwarenessStore(
new Awareness<RawAwarenessState>(this.doc),
merge(clonedeep(FLAGS_PRESET), defaultFlags)
);
this.awarenessSync = new AwarenessEngine(
this.awarenessStore.awareness,
awarenessSources
);
this.docSync = new DocEngine(
this.doc,
docSources.main,
docSources.shadows ?? [],
logger
);
this.blobSync = new BlobEngine(
blobSources.main,
blobSources.shadows ?? [],
logger
);
this.idGenerator = pickIdGenerator(idGenerator, this.doc.clientID);
this.meta = new DocCollectionMeta(this.doc);
this._bindDocMetaEvents();
}
private _bindDocMetaEvents() {
this.meta.docMetaAdded.on(docId => {
const doc = new BlockCollection({
id: docId,
collection: this,
doc: this.doc,
awarenessStore: this.awarenessStore,
idGenerator: this.idGenerator,
});
this.blockCollections.set(doc.id, doc);
this.slots.docAdded.emit(doc.id);
});
this.meta.docMetaUpdated.on(() => this.slots.docUpdated.emit());
this.meta.docMetaRemoved.on(id => {
const space = this.getBlockCollection(id);
if (!space) return;
this.blockCollections.delete(id);
space.remove();
this.slots.docRemoved.emit(id);
});
}
private _hasDoc(docId: string) {
return this.docs.has(docId);
}
/**
* Verify that all data has been successfully saved to the primary storage.
* Return true if the data transfer is complete and it is secure to terminate the synchronization operation.
*/
canGracefulStop() {
this.docSync.canGracefulStop();
}
/**
* By default, only an empty doc will be created.
* If the `init` parameter is passed, a `surface`, `note`, and `paragraph` block
* will be created in the doc simultaneously.
*/
createDoc(options: { id?: string; query?: Query } = {}) {
const { id: docId = this.idGenerator(), query } = options;
if (this._hasDoc(docId)) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
'doc already exists'
);
}
this.meta.addDocMeta({
id: docId,
title: '',
createDate: Date.now(),
tags: [],
});
this.slots.docCreated.emit(docId);
return this.getDoc(docId, { query }) as Doc;
}
dispose() {
this.awarenessStore.destroy();
}
/**
* Terminate the data sync process forcefully, which may cause data loss.
* It is advised to invoke `canGracefulStop` before calling this method.
*/
forceStop() {
this.docSync.forceStop();
this.blobSync.stop();
this.awarenessSync.disconnect();
}
getBlockCollection(docId: string): BlockCollection | null {
const space = this.docs.get(docId) as BlockCollection | undefined;
return space ?? null;
}
getDoc(docId: string, options?: GetDocOptions): Doc | null {
const collection = this.getBlockCollection(docId);
return collection?.getDoc(options) ?? null;
}
removeDoc(docId: string) {
const docMeta = this.meta.getDocMeta(docId);
if (!docMeta) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
`doc meta not found: ${docId}`
);
}
const blockCollection = this.getBlockCollection(docId);
if (!blockCollection) return;
blockCollection.dispose();
this.meta.removeDocMeta(docId);
this.blockCollections.delete(docId);
}
/** Update doc meta state. Note that this intentionally does not mutate doc state. */
setDocMeta(
docId: string,
// You should not update subDocIds directly.
props: Partial<DocMeta>
) {
this.meta.setDocMeta(docId, props);
}
/**
* Start the data sync process
*/
start() {
this.docSync.start();
this.blobSync.start();
this.awarenessSync.connect();
}
/**
* Wait for all data has been successfully saved to the primary storage.
*/
waitForGracefulStop(abort?: AbortSignal) {
return this.docSync.waitForGracefulStop(abort);
}
waitForSynced() {
return this.docSync.waitForSynced();
}
}
@@ -0,0 +1,452 @@
import { type Disposable, Slot } from '@blocksuite/global/utils';
import { signal } from '@preact/signals-core';
import { uuidv4 } from 'lib0/random.js';
import * as Y from 'yjs';
import { Text } from '../../reactive/text.js';
import type { BlockModel } from '../../schema/base.js';
import type { IdGenerator } from '../../utils/id-generator.js';
import type { AwarenessStore, BlockSuiteDoc } from '../../yjs/index.js';
import type { DocCollection } from '../collection.js';
import { DocCRUD } from './crud.js';
import { Doc } from './doc.js';
import type { YBlock } from './index.js';
import type { Query } from './query.js';
export type YBlocks = Y.Map<YBlock>;
/** JSON-serializable properties of a block */
export type BlockSysProps = {
id: string;
flavour: string;
children?: BlockModel[];
};
export type BlockProps = BlockSysProps & Record<string, unknown>;
type DocOptions = {
id: string;
collection: DocCollection;
doc: BlockSuiteDoc;
awarenessStore: AwarenessStore;
idGenerator?: IdGenerator;
};
export type GetDocOptions = {
query?: Query;
readonly?: boolean;
};
export class BlockCollection {
private _awarenessUpdateDisposable: Disposable | null = null;
private readonly _canRedo$ = signal(false);
private readonly _canUndo$ = signal(false);
private readonly _collection: DocCollection;
private readonly _docCRUD: DocCRUD;
private _docMap = {
undefined: new Map<string, Doc>(),
true: new Map<string, Doc>(),
false: new Map<string, Doc>(),
};
// doc/space container.
private _handleYEvents = (events: Y.YEvent<YBlock | Y.Text>[]) => {
events.forEach(event => this._handleYEvent(event));
};
private _history!: Y.UndoManager;
private _historyObserver = () => {
this._updateCanUndoRedoSignals();
this.slots.historyUpdated.emit();
};
private readonly _idGenerator: IdGenerator;
private _initSubDoc = () => {
let subDoc = this.rootDoc.spaces.get(this.id);
if (!subDoc) {
subDoc = new Y.Doc({
guid: this.id,
});
this.rootDoc.spaces.set(this.id, subDoc);
this._loaded = true;
this._onLoadSlot.emit();
} else {
this._loaded = false;
this.rootDoc.on('subdocs', this._onSubdocEvent);
}
return subDoc;
};
private _loaded!: boolean;
private _onLoadSlot = new Slot();
private _onSubdocEvent = ({ loaded }: { loaded: Set<Y.Doc> }): void => {
const result = Array.from(loaded).find(
doc => doc.guid === this._ySpaceDoc.guid
);
if (!result) {
return;
}
this.rootDoc.off('subdocs', this._onSubdocEvent);
this._loaded = true;
this._onLoadSlot.emit();
};
/** Indicate whether the block tree is ready */
private _ready = false;
private _shouldTransact = true;
private _updateCanUndoRedoSignals = () => {
const canRedo = this.readonly ? false : this._history.canRedo();
const canUndo = this.readonly ? false : this._history.canUndo();
if (this._canRedo$.peek() !== canRedo) {
this._canRedo$.value = canRedo;
}
if (this._canUndo$.peek() !== canUndo) {
this._canUndo$.value = canUndo;
}
};
protected readonly _yBlocks: Y.Map<YBlock>;
/**
* @internal Used for convenient access to the underlying Yjs map,
* can be used interchangeably with ySpace
*/
protected readonly _ySpaceDoc: Y.Doc;
readonly awarenessStore: AwarenessStore;
readonly id: string;
readonly rootDoc: BlockSuiteDoc;
readonly slots = {
historyUpdated: new Slot(),
yBlockUpdated: new Slot<
| {
type: 'add';
id: string;
}
| {
type: 'delete';
id: string;
}
>(),
};
// So, we apply a listener at the top level for the flat structure of the current
get awarenessSync() {
return this.collection.awarenessSync;
}
get blobSync() {
return this.collection.blobSync;
}
get canRedo() {
return this._canRedo$.peek();
}
get canRedo$() {
return this._canRedo$;
}
get canUndo() {
return this._canUndo$.peek();
}
get canUndo$() {
return this._canUndo$;
}
get collection() {
return this._collection;
}
get crud() {
return this._docCRUD;
}
get docSync() {
return this.collection.docSync;
}
get history() {
return this._history;
}
get isEmpty() {
return this._yBlocks.size === 0;
}
get loaded() {
return this._loaded;
}
get meta() {
return this.collection.meta.getDocMeta(this.id);
}
get readonly() {
return this.awarenessStore.isReadonly(this);
}
get ready() {
return this._ready;
}
get schema() {
return this.collection.schema;
}
get spaceDoc() {
return this._ySpaceDoc;
}
get Text() {
return Text;
}
get yBlocks() {
return this._yBlocks;
}
constructor({
id,
collection,
doc,
awarenessStore,
idGenerator = uuidv4,
}: DocOptions) {
this.id = id;
this.rootDoc = doc;
this.awarenessStore = awarenessStore;
this._ySpaceDoc = this._initSubDoc();
this._yBlocks = this._ySpaceDoc.getMap('blocks');
this._collection = collection;
this._idGenerator = idGenerator;
this._docCRUD = new DocCRUD(this._yBlocks, collection.schema);
}
private _getReadonlyKey(readonly?: boolean): 'true' | 'false' | 'undefined' {
return (readonly?.toString() as 'true' | 'false') ?? 'undefined';
}
private _handleVersion() {
// Initialization from empty yDoc, indicating that the document is new.
if (!this.collection.meta.hasVersion) {
this.collection.meta.writeVersion(this.collection);
} else {
// Initialization from existing yDoc, indicating that the document is loaded from storage.
if (this.awarenessStore.getFlag('enable_legacy_validation')) {
this.collection.meta.validateVersion(this.collection);
}
}
}
private _handleYBlockAdd(id: string) {
this.slots.yBlockUpdated.emit({ type: 'add', id });
}
private _handleYBlockDelete(id: string) {
this.slots.yBlockUpdated.emit({ type: 'delete', id });
}
private _handleYEvent(event: Y.YEvent<YBlock | Y.Text | Y.Array<unknown>>) {
// event on top-level block store
if (event.target !== this._yBlocks) {
return;
}
event.keys.forEach((value, id) => {
try {
if (value.action === 'add') {
this._handleYBlockAdd(id);
return;
}
if (value.action === 'delete') {
this._handleYBlockDelete(id);
return;
}
} catch (e) {
console.error('An error occurred while handling Yjs event:');
console.error(e);
}
});
}
private _initYBlocks() {
const { _yBlocks } = this;
_yBlocks.observeDeep(this._handleYEvents);
this._history = new Y.UndoManager([_yBlocks], {
trackedOrigins: new Set([this._ySpaceDoc.clientID]),
});
this._history.on('stack-cleared', this._historyObserver);
this._history.on('stack-item-added', this._historyObserver);
this._history.on('stack-item-popped', this._historyObserver);
this._history.on('stack-item-updated', this._historyObserver);
}
/** Capture current operations to undo stack synchronously. */
captureSync() {
this._history.stopCapturing();
}
clear() {
this._yBlocks.clear();
}
clearQuery(query: Query, readonly?: boolean) {
const readonlyKey = this._getReadonlyKey(readonly);
this._docMap[readonlyKey].delete(JSON.stringify(query));
}
destroy() {
this._ySpaceDoc.destroy();
this._onLoadSlot.dispose();
this._loaded = false;
}
dispose() {
this.slots.historyUpdated.dispose();
this._awarenessUpdateDisposable?.dispose();
if (this.ready) {
this._yBlocks.unobserveDeep(this._handleYEvents);
this._yBlocks.clear();
}
}
generateBlockId() {
return this._idGenerator();
}
getDoc({ readonly, query }: GetDocOptions = {}) {
const readonlyKey = this._getReadonlyKey(readonly);
const key = JSON.stringify(query);
if (this._docMap[readonlyKey].has(key)) {
return this._docMap[readonlyKey].get(key)!;
}
const doc = new Doc({
blockCollection: this,
crud: this._docCRUD,
schema: this.collection.schema,
readonly,
query,
});
this._docMap[readonlyKey].set(key, doc);
return doc;
}
load(initFn?: () => void): this {
if (this.ready) {
return this;
}
this._ySpaceDoc.load();
if ((this.collection.meta.docs?.length ?? 0) <= 1) {
this._handleVersion();
}
this._initYBlocks();
this._yBlocks.forEach((_, id) => {
this._handleYBlockAdd(id);
});
this._awarenessUpdateDisposable = this.awarenessStore.slots.update.on(
() => {
// change readonly state will affect the undo/redo state
this._updateCanUndoRedoSignals();
}
);
initFn?.();
this._ready = true;
return this;
}
redo() {
if (this.readonly) {
console.error('cannot modify data in readonly mode');
return;
}
this._history.redo();
}
remove() {
this.destroy();
this.rootDoc.spaces.delete(this.id);
}
resetHistory() {
this._history.clear();
}
/**
* If `shouldTransact` is `false`, the transaction will not be push to the history stack.
*/
transact(fn: () => void, shouldTransact: boolean = this._shouldTransact) {
this._ySpaceDoc.transact(
() => {
try {
fn();
} catch (e) {
console.error(
`An error occurred while Y.doc ${this._ySpaceDoc.guid} transacting:`
);
console.error(e);
}
},
shouldTransact ? this.rootDoc.clientID : null
);
}
// Handle all the events that happen at _any_ level (potentially deep inside the structure).
undo() {
if (this.readonly) {
console.error('cannot modify data in readonly mode');
return;
}
this._history.undo();
}
withoutTransact(callback: () => void) {
this._shouldTransact = false;
callback();
this._shouldTransact = true;
}
}
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,51 @@
import type { Schema } from '../../../schema/index.js';
import { BlockViewType } from '../consts.js';
import type { Doc } from '../doc.js';
import { SyncController } from './sync-controller.js';
import type { BlockOptions, YBlock } from './types.js';
export * from './types.js';
export class Block {
private _syncController: SyncController;
blockViewType: 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?: Doc,
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,377 @@
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 { BlockModel, internalPrimitives } from '../../../schema/base.js';
import type { Schema } from '../../../schema/schema.js';
import type { Doc } from '../doc.js';
import type { YBlock } from './types.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 FIXME: ts error
this.model[keyName] = proxy;
const signalKey = `${keyName}$`;
this._mutex(() => {
if (signalKey in this.model) {
// @ts-expect-error FIXME: ts error
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 FIXME: ts error
delete this.model[keyName];
if (`${keyName}$` in this.model) {
// @ts-expect-error FIXME: ts error
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?: Doc,
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 FIXME: ts error
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 FIXME: ts error
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 FIXME: ts error
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,13 @@
import type * as Y from 'yjs';
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;
};
@@ -0,0 +1,5 @@
export enum BlockViewType {
Bypass = 'bypass',
Display = 'display',
Hidden = 'hidden',
}
@@ -0,0 +1,389 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import * as Y from 'yjs';
import { native2Y } from '../../reactive/index.js';
import {
type BlockModel,
internalPrimitives,
type Schema,
} from '../../schema/index.js';
import type { YBlock } from './index.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,670 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { type Disposable, Slot } from '@blocksuite/global/utils';
import { signal } from '@preact/signals-core';
import type { BlockModel, Schema } from '../../schema/index.js';
import type { DraftModel } from '../../transformer/index.js';
import { syncBlockProps } from '../../utils/utils.js';
import type { BlockOptions } from './block/index.js';
import { Block } from './block/index.js';
import type { BlockCollection, BlockProps } from './block-collection.js';
import type { DocCRUD } from './crud.js';
import { type Query, runQuery } from './query.js';
type DocOptions = {
schema: Schema;
blockCollection: BlockCollection;
crud: DocCRUD;
readonly?: boolean;
query?: Query;
};
export class Doc {
private _runQuery = (block: Block) => {
runQuery(this._query, block);
};
protected readonly _blockCollection: BlockCollection;
protected readonly _blocks = signal<Record<string, Block>>({});
protected readonly _crud: DocCRUD;
protected readonly _disposeBlockUpdated: Disposable;
protected readonly _query: Query = {
match: [],
mode: 'loose',
};
protected readonly _readonly?: boolean;
protected readonly _schema: Schema;
readonly slots: BlockCollection['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._blockCollection.yBlocks;
}
get awarenessStore() {
return this._blockCollection.awarenessStore;
}
get awarenessSync() {
return this.collection.awarenessSync;
}
get blobSync() {
return this.collection.blobSync;
}
get blockCollection() {
return this._blockCollection;
}
get blocks() {
return this._blocks;
}
get blockSize() {
return Object.values(this._blocks.peek()).length;
}
get canRedo() {
return this._blockCollection.canRedo;
}
get canUndo() {
return this._blockCollection.canUndo;
}
get captureSync() {
return this._blockCollection.captureSync.bind(this._blockCollection);
}
get clear() {
return this._blockCollection.clear.bind(this._blockCollection);
}
get collection() {
return this._blockCollection.collection;
}
get docSync() {
return this.collection.docSync;
}
get generateBlockId() {
return this._blockCollection.generateBlockId.bind(this._blockCollection);
}
get history() {
return this._blockCollection.history;
}
get id() {
return this._blockCollection.id;
}
get isEmpty() {
return Object.values(this._blocks.peek()).length === 0;
}
get loaded() {
return this._blockCollection.loaded;
}
get meta() {
return this._blockCollection.meta;
}
get readonly() {
if (this._blockCollection.readonly) {
return true;
}
return this._readonly === true;
}
get ready() {
return this._blockCollection.ready;
}
get redo() {
return this._blockCollection.redo.bind(this._blockCollection);
}
get resetHistory() {
return this._blockCollection.resetHistory.bind(this._blockCollection);
}
get root() {
const rootId = this._crud.root;
if (!rootId) return null;
return this.getBlock(rootId)?.model ?? null;
}
get rootDoc() {
return this._blockCollection.rootDoc;
}
get schema() {
return this._schema;
}
get spaceDoc() {
return this._blockCollection.spaceDoc;
}
get Text() {
return this._blockCollection.Text;
}
get transact() {
return this._blockCollection.transact.bind(this._blockCollection);
}
get undo() {
return this._blockCollection.undo.bind(this._blockCollection);
}
get withoutTransact() {
return this._blockCollection.withoutTransact.bind(this._blockCollection);
}
constructor({ schema, blockCollection, crud, readonly, query }: DocOptions) {
this._blockCollection = blockCollection;
this.slots = {
ready: new Slot(),
rootAdded: new Slot(),
rootDeleted: new Slot(),
blockUpdated: new Slot(),
historyUpdated: this._blockCollection.slots.historyUpdated,
yBlockUpdated: this._blockCollection.slots.yBlockUpdated,
};
this._crud = crud;
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._blockCollection.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._blockCollection.generateBlockId();
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._blockCollection.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,5 @@
export * from './block/index.js';
export * from './block-collection.js';
export * from './consts.js';
export * from './doc.js';
export * from './query.js';
@@ -0,0 +1,85 @@
import isMatch from 'lodash.ismatch';
import type { BlockModel } from '../../schema/index.js';
import type { Block } from './block/index.js';
import { BlockViewType } from './consts.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 !== 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 =
queryMode === 'loose' ? BlockViewType.Display : BlockViewType.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 === BlockViewType.Hidden) {
parentBlock.blockViewType =
mode === 'include' ? BlockViewType.Display : BlockViewType.Bypass;
}
parent = doc.getParent(parent);
}
}
@@ -0,0 +1,54 @@
import {
createAutoIncrementIdGenerator,
createAutoIncrementIdGeneratorByClientId,
type IdGenerator,
nanoid,
uuidv4,
} from '../utils/id-generator.js';
export enum IdGeneratorType {
/**
* **Warning**: This generator mode will crash the collaborative feature
* if multiple clients are adding new blocks.
* Use this mode only if you know what you're doing.
*/
AutoIncrement = 'autoIncrement',
/**
* This generator is trying to fix the real-time collaboration on debug mode.
* This will make generator predictable and won't make conflict
* @link https://docs.yjs.dev/api/faq#i-get-a-new-clientid-for-every-session-is-there-a-way-to-make-it-static-for-a-peer-accessing-the-doc
*/
AutoIncrementByClientId = 'autoIncrementByClientId',
/**
* Default mode, generator for the unpredictable id
*/
NanoID = 'nanoID',
UUIDv4 = 'uuidV4',
}
export function pickIdGenerator(
idGenerator: IdGeneratorType | IdGenerator | undefined,
clientId: number
) {
if (typeof idGenerator === 'function') {
return idGenerator;
}
switch (idGenerator) {
case IdGeneratorType.AutoIncrement: {
return createAutoIncrementIdGenerator();
}
case IdGeneratorType.AutoIncrementByClientId: {
return createAutoIncrementIdGeneratorByClientId(clientId);
}
case IdGeneratorType.UUIDv4: {
return uuidv4;
}
case IdGeneratorType.NanoID:
default: {
return nanoid;
}
}
}
@@ -0,0 +1,6 @@
export type * from './collection.js';
export { DocCollection } from './collection.js';
export type * from './doc/block-collection.js';
export * from './doc/index.js';
export * from './id.js';
export type * from './meta.js';
@@ -0,0 +1,344 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Slot } from '@blocksuite/global/utils';
import type * as Y from 'yjs';
import { COLLECTION_VERSION, PAGE_VERSION } from '../consts.js';
import type { BlockSuiteDoc } from '../yjs/index.js';
import type { DocCollection } from './collection.js';
// please use `declare module '@blocksuite/store'` to extend this interface
export interface DocMeta {
id: string;
title: string;
tags: string[];
createDate: number;
updatedDate?: number;
}
export type Tag = {
id: string;
value: string;
color: string;
};
export type DocsPropertiesMeta = {
tags?: {
options: Tag[];
};
};
export type DocCollectionMetaState = {
pages?: unknown[];
properties?: DocsPropertiesMeta;
workspaceVersion?: number;
pageVersion?: number;
blockVersions?: Record<string, number>;
name?: string;
avatar?: string;
};
export class DocCollectionMeta {
private _handleDocCollectionMetaEvents = (
events: Y.YEvent<Y.Array<unknown> | Y.Text | Y.Map<unknown>>[]
) => {
events.forEach(e => {
const hasKey = (k: string) =>
e.target === this._yMap && e.changes.keys.has(k);
if (
e.target === this.yDocs ||
e.target.parent === this.yDocs ||
hasKey('pages')
) {
this._handleDocMetaEvent();
}
if (hasKey('name') || hasKey('avatar')) {
this._handleCommonFieldsEvent();
}
});
};
private _prevDocs = new Set<string>();
protected readonly _proxy: DocCollectionMetaState;
protected readonly _yMap: Y.Map<
DocCollectionMetaState[keyof DocCollectionMetaState]
>;
commonFieldsUpdated = new Slot();
readonly doc: BlockSuiteDoc;
docMetaAdded = new Slot<string>();
docMetaRemoved = new Slot<string>();
docMetaUpdated = new Slot();
readonly id: string = 'meta';
get avatar() {
return this._proxy.avatar;
}
get blockVersions() {
return this._proxy.blockVersions;
}
get docMetas() {
if (!this._proxy.pages) {
return [] as DocMeta[];
}
return this._proxy.pages as DocMeta[];
}
get docs() {
return this._proxy.pages;
}
get hasVersion() {
if (!this.blockVersions || !this.pageVersion || !this.workspaceVersion) {
return false;
}
return Object.keys(this.blockVersions).length > 0;
}
get name() {
return this._proxy.name;
}
get pageVersion() {
return this._proxy.pageVersion;
}
get properties(): DocsPropertiesMeta {
const meta = this._proxy.properties;
if (!meta) {
return {
tags: {
options: [],
},
};
}
return meta;
}
get workspaceVersion() {
return this._proxy.workspaceVersion;
}
get yDocs() {
return this._yMap.get('pages') as unknown as Y.Array<unknown>;
}
constructor(doc: BlockSuiteDoc) {
this.doc = doc;
this._yMap = doc.getMap(this.id);
this._proxy = doc.getMapProxy<string, DocCollectionMetaState>(this.id);
this._yMap.observeDeep(this._handleDocCollectionMetaEvents);
}
private _handleCommonFieldsEvent() {
this.commonFieldsUpdated.emit();
}
private _handleDocMetaEvent() {
const { docMetas, _prevDocs } = this;
const newDocs = new Set<string>();
docMetas.forEach(docMeta => {
if (!_prevDocs.has(docMeta.id)) {
this.docMetaAdded.emit(docMeta.id);
}
newDocs.add(docMeta.id);
});
_prevDocs.forEach(prevDocId => {
const isRemoved = newDocs.has(prevDocId) === false;
if (isRemoved) {
this.docMetaRemoved.emit(prevDocId);
}
});
this._prevDocs = newDocs;
this.docMetaUpdated.emit();
}
addDocMeta(doc: DocMeta, index?: number) {
this.doc.transact(() => {
if (!this.docs) {
return;
}
const docs = this.docs as unknown[];
if (index === undefined) {
docs.push(doc);
} else {
docs.splice(index, 0, doc);
}
}, this.doc.clientID);
}
getDocMeta(id: string) {
return this.docMetas.find(doc => doc.id === id);
}
initialize() {
if (!this._proxy.pages) {
this._proxy.pages = [];
}
}
removeDocMeta(id: string) {
// you cannot delete a doc if there's no doc
if (!this.docs) {
return;
}
const docMeta = this.docMetas;
const index = docMeta.findIndex((doc: DocMeta) => id === doc.id);
if (index === -1) {
return;
}
this.doc.transact(() => {
if (!this.docs) {
return;
}
this.docs.splice(index, 1);
}, this.doc.clientID);
}
setAvatar(avatar: string) {
this.doc.transact(() => {
this._proxy.avatar = avatar;
}, this.doc.clientID);
}
setDocMeta(id: string, props: Partial<DocMeta>) {
const docs = (this.docs as DocMeta[]) ?? [];
const index = docs.findIndex((doc: DocMeta) => id === doc.id);
this.doc.transact(() => {
if (!this.docs) {
return;
}
if (index === -1) return;
const doc = this.docs[index] as Record<string, unknown>;
Object.entries(props).forEach(([key, value]) => {
doc[key] = value;
});
}, this.doc.clientID);
}
setName(name: string) {
this.doc.transact(() => {
this._proxy.name = name;
}, this.doc.clientID);
}
setProperties(meta: DocsPropertiesMeta) {
this._proxy.properties = meta;
this.docMetaUpdated.emit();
}
/**
* @deprecated Only used for legacy doc version validation
*/
validateVersion(collection: DocCollection) {
const workspaceVersion = this._proxy.workspaceVersion;
if (!workspaceVersion) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
'Invalid workspace data, workspace version is missing. Please make sure the data is valid.'
);
}
if (workspaceVersion < COLLECTION_VERSION) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
`Workspace version ${workspaceVersion} is outdated. Please upgrade the editor.`
);
}
const pageVersion = this._proxy.pageVersion;
if (!pageVersion) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
'Invalid workspace data, page version is missing. Please make sure the data is valid.'
);
}
if (pageVersion < PAGE_VERSION) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
`Doc version ${pageVersion} is outdated. Please upgrade the editor.`
);
}
const blockVersions = { ...this._proxy.blockVersions };
if (!blockVersions) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
'Invalid workspace data, versions data is missing. Please make sure the data is valid'
);
}
const dataFlavours = Object.keys(blockVersions);
if (dataFlavours.length === 0) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
'Invalid workspace data, missing versions field. Please make sure the data is valid.'
);
}
dataFlavours.forEach(dataFlavour => {
const dataVersion = blockVersions[dataFlavour] as number;
const editorVersion =
collection.schema.flavourSchemaMap.get(dataFlavour)?.version;
if (!editorVersion) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
`Editor missing ${dataFlavour} flavour. Please make sure this block flavour is registered.`
);
} else if (dataVersion > editorVersion) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
`Editor doesn't support ${dataFlavour}@${dataVersion}. Please upgrade the editor.`
);
} else if (dataVersion < editorVersion) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
`In workspace data, the block flavour ${dataFlavour}@${dataVersion} is outdated. Please downgrade the editor or try data migration.`
);
}
});
}
/**
* @internal Only for doc initialization
*/
writeVersion(collection: DocCollection) {
const { blockVersions, pageVersion, workspaceVersion } = this._proxy;
if (!workspaceVersion) {
this._proxy.workspaceVersion = COLLECTION_VERSION;
} else {
console.error('Workspace version is already set');
}
if (!pageVersion) {
this._proxy.pageVersion = PAGE_VERSION;
} else {
console.error('Doc version is already set');
}
if (!blockVersions) {
const _versions: Record<string, number> = {};
collection.schema.flavourSchemaMap.forEach((schema, flavour) => {
_versions[flavour] = schema.version;
});
this._proxy.blockVersions = _versions;
} else {
console.error('Block versions is already set');
}
}
}
@@ -0,0 +1,104 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
interface BlobCRUD {
get: (key: string) => Promise<Blob | null> | Blob | null;
set: (key: string, value: Blob) => Promise<string> | string;
delete: (key: string) => Promise<void> | void;
list: () => Promise<string[]> | string[];
}
type AssetsManagerConfig = {
blob: BlobCRUD;
};
function makeNewNameWhenConflict(names: Set<string>, name: string) {
let i = 1;
const ext = name.split('.').at(-1) ?? '';
let newName = name.replace(new RegExp(`.${ext}$`), ` (${i}).${ext}`);
while (names.has(newName)) {
newName = name.replace(new RegExp(`.${ext}$`), ` (${i}).${ext}`);
i++;
}
return newName;
}
export class AssetsManager {
private readonly _assetsMap = new Map<string, Blob>();
private readonly _blob: BlobCRUD;
private readonly _names = new Set<string>();
private readonly _pathBlobIdMap = new Map<string, string>();
constructor(options: AssetsManagerConfig) {
this._blob = options.blob;
}
cleanup() {
this._assetsMap.clear();
this._names.clear();
}
getAssets() {
return this._assetsMap;
}
getPathBlobIdMap() {
return this._pathBlobIdMap;
}
isEmpty() {
return this._assetsMap.size === 0;
}
async readFromBlob(blobId: string) {
if (this._assetsMap.has(blobId)) return;
const blob = await this._blob.get(blobId);
if (!blob) {
console.error(`Blob ${blobId} not found in blob manager`);
return;
}
if (blob instanceof File) {
let file = blob;
if (this._names.has(blob.name)) {
const newName = makeNewNameWhenConflict(this._names, blob.name);
file = new File([blob], newName, { type: blob.type });
}
this._assetsMap.set(blobId, file);
this._names.add(file.name);
return;
}
if (blob.type && blob.type !== 'application/octet-stream') {
this._assetsMap.set(blobId, blob);
return;
}
// Guess the file type from the buffer
const buffer = await blob.arrayBuffer();
const FileType = await import('file-type');
const fileType = await FileType.fileTypeFromBuffer(buffer);
if (fileType) {
const file = new File([blob], '', { type: fileType.mime });
this._assetsMap.set(blobId, file);
return;
}
this._assetsMap.set(blobId, blob);
}
async writeToBlob(blobId: string) {
const blob = this._assetsMap.get(blobId);
if (!blob) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
`Blob ${blobId} not found in assets manager`
);
}
const exists = (await this._blob.get(blobId)) !== null;
if (exists) {
return;
}
await this._blob.set(blobId, blob);
}
}
@@ -0,0 +1,78 @@
import type { BlockModel, InternalPrimitives } from '../schema/index.js';
import { internalPrimitives } from '../schema/index.js';
import type { AssetsManager } from './assets.js';
import type { DraftModel } from './draft.js';
import { fromJSON, toJSON } from './json.js';
import type { BlockSnapshot } from './type.js';
type BlockSnapshotLeaf = Pick<
BlockSnapshot,
'id' | 'flavour' | 'props' | 'version'
>;
export type FromSnapshotPayload = {
json: BlockSnapshotLeaf;
assets: AssetsManager;
children: BlockSnapshot[];
};
export type ToSnapshotPayload<Props extends object> = {
model: DraftModel<BlockModel<Props>>;
assets: AssetsManager;
};
export type SnapshotNode<Props extends object> = {
id: string;
flavour: string;
version: number;
props: Props;
};
export class BaseBlockTransformer<Props extends object = object> {
protected _internal: InternalPrimitives = internalPrimitives;
protected _propsFromSnapshot(propsJson: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(propsJson).map(([key, value]) => {
return [key, fromJSON(value)];
})
) as Props;
}
protected _propsToSnapshot(model: DraftModel) {
return Object.fromEntries(
model.keys.map(key => {
const value = model[key as keyof typeof model];
return [key, toJSON(value)];
})
);
}
fromSnapshot({
json,
}: FromSnapshotPayload): Promise<SnapshotNode<Props>> | SnapshotNode<Props> {
const { flavour, id, version, props: _props } = json;
const props = this._propsFromSnapshot(_props);
return {
id,
flavour,
version: version ?? -1,
props,
};
}
toSnapshot({ model }: ToSnapshotPayload<Props>): BlockSnapshotLeaf {
const { id, flavour, version } = model;
const props = this._propsToSnapshot(model);
return {
id,
flavour,
version,
props,
};
}
}
@@ -0,0 +1,35 @@
import type { BlockModel } from '../schema/base.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,8 @@
export * from './assets.js';
export * from './base.js';
export * from './draft.js';
export * from './job.js';
export * from './json.js';
export * from './middleware.js';
export * from './slice.js';
export * from './type.js';
@@ -0,0 +1,630 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { nextTick, Slot } from '@blocksuite/global/utils';
import type { BlockModel, BlockSchemaType } from '../schema/index.js';
import type { Doc, DocCollection, DocMeta } from '../store/index.js';
import { AssetsManager } from './assets.js';
import { BaseBlockTransformer } from './base.js';
import type { DraftModel } from './draft.js';
import type {
BeforeExportPayload,
BeforeImportPayload,
FinalPayload,
JobMiddleware,
JobSlots,
} from './middleware.js';
import { Slice } from './slice.js';
import type {
BlockSnapshot,
CollectionInfoSnapshot,
DocSnapshot,
SliceSnapshot,
} from './type.js';
import {
BlockSnapshotSchema,
CollectionInfoSnapshotSchema,
DocSnapshotSchema,
SliceSnapshotSchema,
} from './type.js';
export type JobConfig = {
collection: DocCollection;
middlewares?: JobMiddleware[];
};
interface FlatSnapshot {
snapshot: BlockSnapshot;
parentId?: string;
index?: number;
}
interface DraftBlockTreeNode {
draft: DraftModel;
snapshot: BlockSnapshot;
children: Array<DraftBlockTreeNode>;
}
// The number of blocks to insert in one batch
const BATCH_SIZE = 100;
export class Job {
private readonly _adapterConfigs = new Map<string, string>();
private readonly _assetsManager: AssetsManager;
private readonly _collection: DocCollection;
private readonly _slots: JobSlots = {
beforeImport: new Slot<BeforeImportPayload>(),
afterImport: new Slot<FinalPayload>(),
beforeExport: new Slot<BeforeExportPayload>(),
afterExport: new Slot<FinalPayload>(),
};
blockToSnapshot = (model: DraftModel): BlockSnapshot | undefined => {
try {
const snapshot = this._blockToSnapshot(model);
BlockSnapshotSchema.parse(snapshot);
return snapshot;
} catch (error) {
console.error(`Error when transforming block to snapshot:`);
console.error(error);
return;
}
};
collectionInfoToSnapshot = (): CollectionInfoSnapshot | undefined => {
try {
this._slots.beforeExport.emit({
type: 'info',
});
const collectionMeta = this._getCollectionMeta();
const snapshot: CollectionInfoSnapshot = {
type: 'info',
id: this._collection.id,
...collectionMeta,
};
this._slots.afterExport.emit({
type: 'info',
snapshot,
});
CollectionInfoSnapshotSchema.parse(snapshot);
return snapshot;
} catch (error) {
console.error(`Error when transforming collection info to snapshot:`);
console.error(error);
return;
}
};
docToSnapshot = (doc: Doc): DocSnapshot | undefined => {
try {
this._slots.beforeExport.emit({
type: 'page',
page: doc,
});
const rootModel = doc.root;
const meta = this._exportDocMeta(doc);
if (!rootModel) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
'Root block not found in doc'
);
}
const blocks = this.blockToSnapshot(rootModel);
if (!blocks) {
return;
}
const docSnapshot: DocSnapshot = {
type: 'page',
meta,
blocks,
};
this._slots.afterExport.emit({
type: 'page',
page: doc,
snapshot: docSnapshot,
});
DocSnapshotSchema.parse(docSnapshot);
return docSnapshot;
} catch (error) {
console.error(`Error when transforming doc to snapshot:`);
console.error(error);
return;
}
};
sliceToSnapshot = (slice: Slice): SliceSnapshot | undefined => {
try {
this._slots.beforeExport.emit({
type: 'slice',
slice,
});
const { content, pageId, workspaceId } = slice.data;
const contentSnapshot = [];
for (const block of content) {
const blockSnapshot = this.blockToSnapshot(block);
if (!blockSnapshot) {
return;
}
contentSnapshot.push(blockSnapshot);
}
const snapshot: SliceSnapshot = {
type: 'slice',
workspaceId,
pageId,
content: contentSnapshot,
};
this._slots.afterExport.emit({
type: 'slice',
slice,
snapshot,
});
SliceSnapshotSchema.parse(snapshot);
return snapshot;
} catch (error) {
console.error(`Error when transforming slice to snapshot:`);
console.error(error);
return;
}
};
snapshotToBlock = async (
snapshot: BlockSnapshot,
doc: Doc,
parent?: string,
index?: number
): Promise<BlockModel | undefined> => {
try {
BlockSnapshotSchema.parse(snapshot);
const model = await this._snapshotToBlock(snapshot, doc, parent, index);
if (!model) return;
return model;
} catch (error) {
console.error(`Error when transforming snapshot to block:`);
console.error(error);
return;
}
};
snapshotToDoc = async (snapshot: DocSnapshot): Promise<Doc | undefined> => {
try {
this._slots.beforeImport.emit({
type: 'page',
snapshot,
});
DocSnapshotSchema.parse(snapshot);
const { meta, blocks } = snapshot;
const doc = this._collection.createDoc({ id: meta.id });
doc.load();
await this.snapshotToBlock(blocks, doc);
this._slots.afterImport.emit({
type: 'page',
snapshot,
page: doc,
});
return doc;
} catch (error) {
console.error(`Error when transforming snapshot to doc:`);
console.error(error);
return;
}
};
snapshotToModelData = async (snapshot: BlockSnapshot) => {
try {
const { children, flavour, props, id } = snapshot;
const schema = this._getSchema(flavour);
const snapshotLeaf = {
id,
flavour,
props,
};
const transformer = this._getTransformer(schema);
const modelData = await transformer.fromSnapshot({
json: snapshotLeaf,
assets: this._assetsManager,
children,
});
return modelData;
} catch (error) {
console.error(`Error when transforming snapshot to model data:`);
console.error(error);
return;
}
};
snapshotToSlice = async (
snapshot: SliceSnapshot,
doc: Doc,
parent?: string,
index?: number
): Promise<Slice | undefined> => {
SliceSnapshotSchema.parse(snapshot);
try {
this._slots.beforeImport.emit({
type: 'slice',
snapshot,
});
const { content, workspaceId, pageId } = snapshot;
// Create a temporary root snapshot to encompass all content blocks
const tmpRootSnapshot: BlockSnapshot = {
id: 'temporary-root',
flavour: 'affine:page',
props: {},
type: 'block',
children: content,
};
for (const block of content) {
this._triggerBeforeImportEvent(block, parent, index);
}
const flatSnapshots: FlatSnapshot[] = [];
this._flattenSnapshot(tmpRootSnapshot, flatSnapshots, parent, index);
const blockTree = await this._convertFlatSnapshots(flatSnapshots);
await this._insertBlockTree(blockTree.children, doc, parent, index);
const contentBlocks = blockTree.children
.map(tree => doc.getBlockById(tree.draft.id))
.filter(Boolean) as DraftModel[];
const slice = new Slice({
content: contentBlocks,
workspaceId,
pageId,
});
this._slots.afterImport.emit({
type: 'slice',
snapshot,
slice,
});
return slice;
} catch (error) {
console.error(`Error when transforming snapshot to slice:`);
console.error(error);
return;
}
};
walk = (snapshot: DocSnapshot, callback: (block: BlockSnapshot) => void) => {
const walk = (block: BlockSnapshot) => {
try {
callback(block);
} catch (error) {
console.error(`Error when walking snapshot:`);
console.error(error);
}
if (block.children) {
block.children.forEach(walk);
}
};
walk(snapshot.blocks);
};
get adapterConfigs() {
return this._adapterConfigs;
}
get assets() {
return this._assetsManager.getAssets();
}
get assetsManager() {
return this._assetsManager;
}
get collection() {
return this._collection;
}
constructor({ collection, middlewares = [] }: JobConfig) {
this._collection = collection;
this._assetsManager = new AssetsManager({ blob: collection.blobSync });
middlewares.forEach(middleware => {
middleware({
slots: this._slots,
assetsManager: this._assetsManager,
collection: this._collection,
adapterConfigs: this._adapterConfigs,
});
});
}
private _blockToSnapshot(model: DraftModel): BlockSnapshot {
this._slots.beforeExport.emit({
type: 'block',
model,
});
const schema = this._getSchema(model.flavour);
const transformer = this._getTransformer(schema);
const snapshotLeaf = transformer.toSnapshot({
model,
assets: this._assetsManager,
});
const children = model.children.map(child => {
return this._blockToSnapshot(child);
});
const snapshot: BlockSnapshot = {
type: 'block',
...snapshotLeaf,
children,
};
this._slots.afterExport.emit({
type: 'block',
model,
snapshot,
});
return snapshot;
}
private async _convertFlatSnapshots(flatSnapshots: FlatSnapshot[]) {
// Phase 1: Convert snapshots to draft models in series
// This is not time-consuming, this is faster than Promise.all
const draftModels = [];
for (const flat of flatSnapshots) {
const draft = await this._convertSnapshotToDraftModel(flat);
if (draft) {
draft.id = flat.snapshot.id;
}
draftModels.push({
draft,
snapshot: flat.snapshot,
parentId: flat.parentId,
index: flat.index,
});
}
// Phase 2: Filter out the models that failed to convert
const validDraftModels = draftModels.filter(item => !!item.draft) as {
draft: DraftModel;
snapshot: BlockSnapshot;
parentId?: string;
index?: number;
}[];
// Phase 3: Rebuild the block trees
const blockTree = this._rebuildBlockTree(validDraftModels);
return blockTree;
}
private async _convertSnapshotToDraftModel(
flat: FlatSnapshot
): Promise<DraftModel | undefined> {
try {
const { children, flavour } = flat.snapshot;
const schema = this._getSchema(flavour);
const transformer = this._getTransformer(schema);
const { props } = await transformer.fromSnapshot({
json: {
id: flat.snapshot.id,
flavour: flat.snapshot.flavour,
props: flat.snapshot.props,
},
assets: this._assetsManager,
children,
});
return {
id: flat.snapshot.id,
flavour: flat.snapshot.flavour,
children: [],
...props,
} as DraftModel;
} catch (error) {
console.error(`Error when transforming snapshot to model data:`);
console.error(error);
return;
}
}
private _exportDocMeta(doc: Doc): DocSnapshot['meta'] {
const docMeta = doc.meta;
if (!docMeta) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
'Doc meta not found'
);
}
return {
id: docMeta.id,
title: docMeta.title,
createDate: docMeta.createDate,
tags: [], // for backward compatibility
};
}
private _flattenSnapshot(
snapshot: BlockSnapshot,
flatSnapshots: FlatSnapshot[],
parentId?: string,
index?: number
) {
flatSnapshots.push({ snapshot, parentId, index });
if (snapshot.children) {
snapshot.children.forEach((child, idx) => {
this._flattenSnapshot(child, flatSnapshots, snapshot.id, idx);
});
}
}
private _getCollectionMeta() {
const { meta } = this._collection;
const { docs } = meta;
if (!docs) {
throw new BlockSuiteError(ErrorCode.TransformerError, 'Docs not found');
}
return {
properties: {}, // for backward compatibility
pages: JSON.parse(JSON.stringify(docs)) as DocMeta[],
};
}
private _getSchema(flavour: string) {
const schema = this._collection.schema.flavourSchemaMap.get(flavour);
if (!schema) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
`Flavour schema not found for ${flavour}`
);
}
return schema;
}
private _getTransformer(schema: BlockSchemaType) {
return schema.transformer?.() ?? new BaseBlockTransformer();
}
private async _insertBlockTree(
nodes: DraftBlockTreeNode[],
doc: Doc,
parentId?: string,
startIndex?: number,
counter: number = 0
) {
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index];
const { draft } = node;
const { id, flavour } = draft;
const actualIndex =
startIndex !== undefined ? startIndex + index : undefined;
doc.addBlock(
flavour as BlockSuite.Flavour,
draft as object,
parentId,
actualIndex
);
const model = doc.getBlock(id)?.model;
if (!model) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
`Block not found by id ${id}`
);
}
this._slots.afterImport.emit({
type: 'block',
model,
snapshot: node.snapshot,
});
counter++;
if (counter % BATCH_SIZE === 0) {
await nextTick();
}
if (node.children.length > 0) {
counter = await this._insertBlockTree(
node.children,
doc,
id,
undefined,
counter
);
}
}
return counter;
}
private _rebuildBlockTree(
draftModels: {
draft: DraftModel;
snapshot: BlockSnapshot;
parentId?: string;
index?: number;
}[]
): DraftBlockTreeNode {
const nodeMap = new Map<string, DraftBlockTreeNode>();
// First pass: create nodes and add them to the map
draftModels.forEach(({ draft, snapshot }) => {
nodeMap.set(draft.id, { draft, snapshot, children: [] });
});
const root = nodeMap.get(draftModels[0].draft.id) as DraftBlockTreeNode;
// Second pass: build the tree structure
draftModels.forEach(({ draft, parentId, index }) => {
const node = nodeMap.get(draft.id);
if (!node) return;
if (parentId) {
const parentNode = nodeMap.get(parentId);
if (parentNode && index !== undefined) {
parentNode.children[index] = node;
}
}
});
if (!root) {
throw new Error('No root node found in the tree');
}
return root;
}
private async _snapshotToBlock(
snapshot: BlockSnapshot,
doc: Doc,
parent?: string,
index?: number
): Promise<BlockModel | null> {
this._triggerBeforeImportEvent(snapshot, parent, index);
const flatSnapshots: FlatSnapshot[] = [];
this._flattenSnapshot(snapshot, flatSnapshots, parent, index);
const blockTree = await this._convertFlatSnapshots(flatSnapshots);
await this._insertBlockTree([blockTree], doc, parent, index);
return doc.getBlock(snapshot.id)?.model ?? null;
}
private _triggerBeforeImportEvent(
snapshot: BlockSnapshot,
parent?: string,
index?: number
) {
const traverseAndTrigger = (
node: BlockSnapshot,
parent?: string,
index?: number
) => {
this._slots.beforeImport.emit({
type: 'block',
snapshot: node,
parent: parent,
index: index,
});
if (node.children) {
node.children.forEach((child, idx) => {
traverseAndTrigger(child, node.id, idx);
});
}
};
traverseAndTrigger(snapshot, parent, index);
}
reset() {
this._assetsManager.cleanup();
}
}
@@ -0,0 +1,51 @@
import { NATIVE_UNIQ_IDENTIFIER, TEXT_UNIQ_IDENTIFIER } from '../consts.js';
import { Boxed } from '../reactive/boxed.js';
import { isPureObject } from '../reactive/index.js';
import { Text } from '../reactive/text.js';
export function toJSON(value: unknown): unknown {
if (value instanceof Boxed) {
return {
[NATIVE_UNIQ_IDENTIFIER]: true,
value: value.getValue(),
};
}
if (value instanceof Text) {
return {
[TEXT_UNIQ_IDENTIFIER]: true,
delta: value.yText.toDelta(),
};
}
if (Array.isArray(value)) {
return value.map(toJSON);
}
if (isPureObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, value]) => {
return [key, toJSON(value)];
})
);
}
return value;
}
export function fromJSON(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(fromJSON);
}
if (typeof value === 'object' && value != null) {
if (Reflect.has(value, NATIVE_UNIQ_IDENTIFIER)) {
return new Boxed(Reflect.get(value, 'value'));
}
if (Reflect.has(value, TEXT_UNIQ_IDENTIFIER)) {
return new Text(Reflect.get(value, 'delta'));
}
return Object.fromEntries(
Object.entries(value).map(([key, value]) => {
return [key, fromJSON(value)];
})
);
}
return value;
}
@@ -0,0 +1,88 @@
import type { Slot } from '@blocksuite/global/utils';
import type { Doc, DocCollection } from '../store/index.js';
import type { AssetsManager } from './assets.js';
import type { DraftModel } from './draft.js';
import type { Slice } from './slice.js';
import type {
BlockSnapshot,
CollectionInfoSnapshot,
DocSnapshot,
SliceSnapshot,
} from './type.js';
export type BeforeImportPayload =
| {
snapshot: BlockSnapshot;
type: 'block';
parent?: string;
index?: number;
}
| {
snapshot: SliceSnapshot;
type: 'slice';
}
| {
snapshot: DocSnapshot;
type: 'page';
}
| {
snapshot: CollectionInfoSnapshot;
type: 'info';
};
export type BeforeExportPayload =
| {
model: DraftModel;
type: 'block';
}
| {
page: Doc;
type: 'page';
}
| {
slice: Slice;
type: 'slice';
}
| {
type: 'info';
};
export type FinalPayload =
| {
snapshot: BlockSnapshot;
type: 'block';
model: DraftModel;
parent?: string;
index?: number;
}
| {
snapshot: DocSnapshot;
type: 'page';
page: Doc;
}
| {
snapshot: SliceSnapshot;
type: 'slice';
slice: Slice;
}
| {
snapshot: CollectionInfoSnapshot;
type: 'info';
};
export type JobSlots = {
beforeImport: Slot<BeforeImportPayload>;
afterImport: Slot<FinalPayload>;
beforeExport: Slot<BeforeExportPayload>;
afterExport: Slot<FinalPayload>;
};
type JobMiddlewareOptions = {
collection: DocCollection;
assetsManager: AssetsManager;
slots: JobSlots;
adapterConfigs: Map<string, string>;
};
export type JobMiddleware = (options: JobMiddlewareOptions) => void;
@@ -0,0 +1,32 @@
import type { Doc } from '../store/index.js';
import type { DraftModel } from './draft.js';
type SliceData = {
content: DraftModel[];
workspaceId: string;
pageId: string;
};
export class Slice {
get content() {
return this.data.content;
}
get docId() {
return this.data.pageId;
}
get workspaceId() {
return this.data.workspaceId;
}
constructor(readonly data: SliceData) {}
static fromModels(doc: Doc, models: DraftModel[]) {
return new Slice({
content: models,
workspaceId: doc.collection.id,
pageId: doc.id,
});
}
}
@@ -0,0 +1,67 @@
import { z } from 'zod';
import type { DocMeta, DocsPropertiesMeta } from '../store/meta.js';
export type BlockSnapshot = {
type: 'block';
id: string;
flavour: string;
version?: number;
props: Record<string, unknown>;
children: BlockSnapshot[];
};
export const BlockSnapshotSchema: z.ZodType<BlockSnapshot> = z.object({
type: z.literal('block'),
id: z.string(),
flavour: z.string(),
version: z.number().optional(),
props: z.record(z.unknown()),
children: z.lazy(() => BlockSnapshotSchema.array()),
});
export type SliceSnapshot = {
type: 'slice';
content: BlockSnapshot[];
workspaceId: string;
pageId: string;
};
export const SliceSnapshotSchema: z.ZodType<SliceSnapshot> = z.object({
type: z.literal('slice'),
content: BlockSnapshotSchema.array(),
workspaceId: z.string(),
pageId: z.string(),
});
export type CollectionInfoSnapshot = {
id: string;
type: 'info';
properties: DocsPropertiesMeta;
};
export const CollectionInfoSnapshotSchema: z.ZodType<CollectionInfoSnapshot> =
z.object({
id: z.string(),
type: z.literal('info'),
properties: z.record(z.any()),
});
export type DocSnapshot = {
type: 'page';
meta: DocMeta;
blocks: BlockSnapshot;
};
const DocMetaSchema = z.object({
id: z.string(),
title: z.string(),
createDate: z.number(),
tags: z.array(z.string()),
});
export const DocSnapshotSchema: z.ZodType<DocSnapshot> = z.object({
type: z.literal('page'),
meta: DocMetaSchema,
blocks: BlockSnapshotSchema,
});
@@ -0,0 +1,68 @@
import { BlockModel } from '../schema/base.js';
function isBlockModel(a: unknown): a is BlockModel {
return a instanceof BlockModel;
}
/**
* Ported from https://github.com/vuejs/core/blob/main/packages/runtime-core/src/customFormatter.ts
*
* See [Custom Object Formatters in Chrome DevTools](https://docs.google.com/document/d/1FTascZXT9cxfetuPRT2eXPQKXui4nWFivUnS_335T3U)
*/
function initCustomFormatter() {
if (
!(process.env.NODE_ENV === 'development') ||
typeof window === 'undefined'
) {
return;
}
const bannerStyle = {
style:
'color: #eee; background: #3F6FDB; margin-right: 5px; padding: 2px; border-radius: 4px',
};
const typeStyle = {
style:
'color: #eee; background: #DB6D56; margin-right: 5px; padding: 2px; border-radius: 4px',
};
// custom formatter for Chrome
// https://www.mattzeunert.com/2016/02/19/custom-chrome-devtools-object-formatters.html
const formatter = {
header(obj: unknown, config = { expand: false }) {
if (!isBlockModel(obj) || config.expand) {
return null;
}
if (obj.text) {
return [
'div',
{},
['span', bannerStyle, obj.constructor.name],
['span', typeStyle, obj.flavour],
obj.text.toString(),
];
}
return [
'div',
{},
['span', bannerStyle, obj.constructor.name],
['span', typeStyle, obj.flavour],
];
},
hasBody() {
return true;
},
body(obj: unknown) {
return ['object', { object: obj, config: { expand: true } }];
},
};
if ((window as any).devtoolsFormatters) {
(window as any).devtoolsFormatters.push(formatter);
} else {
(window as any).devtoolsFormatters = [formatter];
}
}
initCustomFormatter();
@@ -0,0 +1,24 @@
import { uuidv4 as uuidv4IdGenerator } from 'lib0/random.js';
import { nanoid as nanoidGenerator } from 'nanoid';
export type IdGenerator = () => string;
export function createAutoIncrementIdGenerator(): IdGenerator {
let i = 0;
return () => (i++).toString();
}
export function createAutoIncrementIdGeneratorByClientId(
clientId: number
): IdGenerator {
let i = 0;
return () => `${clientId}:${i++}`;
}
export const uuidv4: IdGenerator = () => {
return uuidv4IdGenerator();
};
export const nanoid: IdGenerator = () => {
return nanoidGenerator(10);
};
+165
View File
@@ -0,0 +1,165 @@
import * as Y from 'yjs';
type DocRecord = Record<
string,
{
'sys:id': string;
'sys:flavour': string;
'sys:children': string[];
[id: string]: unknown;
}
>;
export interface JSXElement {
// Ad-hoc for `ReactTestComponent` identify.
// Use ReactTestComponent serializer prevent snapshot be be wrapped in a string, which cases " to be escaped.
// See https://github.com/facebook/jest/blob/f1263368cc85c3f8b70eaba534ddf593392c44f3/packages/pretty-format/src/plugins/ReactTestComponent.ts#L78-L79
$$typeof: symbol | 0xea71357;
type: string;
props: { 'prop:text'?: string | JSXElement } & Record<string, unknown>;
children?: null | (JSXElement | string | number)[];
}
// Ad-hoc for `ReactTestComponent` identify.
// See https://github.com/facebook/jest/blob/f1263368cc85c3f8b70eaba534ddf593392c44f3/packages/pretty-format/src/plugins/ReactTestComponent.ts#L26-L29
const testSymbol = Symbol.for('react.test.json');
function isValidRecord(data: unknown): data is DocRecord {
if (typeof data !== 'object' || data === null) {
return false;
}
// TODO enhance this check
return true;
}
const IGNORED_PROPS = new Set([
'sys:id',
'sys:version',
'sys:flavour',
'sys:children',
'prop:xywh',
'prop:cells',
'prop:elements',
]);
export function yDocToJSXNode(
serializedDoc: Record<string, unknown>,
nodeId: string
): JSXElement {
if (!isValidRecord(serializedDoc)) {
throw new Error('Failed to parse doc record! Invalid data.');
}
const node = serializedDoc[nodeId];
if (!node) {
throw new Error(
`Failed to parse doc record! Node not found! id: ${nodeId}.`
);
}
// TODO maybe need set PascalCase
const flavour = node['sys:flavour'];
// TODO maybe need check children recursively nested
const children = node['sys:children'];
const props = Object.fromEntries(
Object.entries(node).filter(([key]) => !IGNORED_PROPS.has(key))
);
if ('prop:text' in props && props['prop:text'] instanceof Array) {
props['prop:text'] = parseDelta(props['prop:text'] as DeltaText);
}
if ('prop:title' in props && props['prop:title'] instanceof Array) {
props['prop:title'] = parseDelta(props['prop:title'] as DeltaText);
}
if ('prop:columns' in props && props['prop:columns'] instanceof Array) {
props['prop:columns'] = `Array [${props['prop:columns'].length}]`;
}
if ('prop:views' in props && props['prop:views'] instanceof Array) {
props['prop:views'] = `Array [${props['prop:views'].length}]`;
}
return {
$$typeof: testSymbol,
type: flavour,
props,
children: children?.map(id => yDocToJSXNode(serializedDoc, id)) ?? [],
};
}
export function serializeYDoc(doc: Y.Doc) {
const json: Record<string, unknown> = {};
doc.share.forEach((value, key) => {
if (value instanceof Y.Map) {
json[key] = serializeYMap(value);
} else {
json[key] = value.toJSON();
}
});
return json;
}
function serializeY(value: unknown): unknown {
if (value instanceof Y.Doc) {
return serializeYDoc(value);
}
if (value instanceof Y.Map) {
return serializeYMap(value);
}
if (value instanceof Y.Text) {
return serializeYText(value);
}
if (value instanceof Y.Array) {
return value.toArray().map(x => serializeY(x));
}
if (value instanceof Y.AbstractType) {
return value.toJSON();
}
return value;
}
function serializeYMap(map: Y.Map<unknown>) {
const json: Record<string, unknown> = {};
map.forEach((value, key) => {
json[key] = serializeY(value);
});
return json;
}
type DeltaText = {
insert: string;
attributes?: Record<string, unknown>;
}[];
function serializeYText(text: Y.Text): DeltaText {
const delta = text.toDelta();
return delta;
}
function parseDelta(text: DeltaText) {
if (!text.length) {
return undefined;
}
if (text.length === 1 && !text[0].attributes) {
// just plain text
return text[0].insert;
}
return {
// The `Symbol.for('react.fragment')` will render as `<React.Fragment>`
// so we use a empty string to render it as `<>`.
// But it will empty children ad `< />`
// so we return `undefined` directly if not delta text.
$$typeof: testSymbol, // Symbol.for('react.element'),
type: '', // Symbol.for('react.fragment'),
props: {},
children: text?.map(({ insert, attributes }) => ({
$$typeof: testSymbol,
type: 'text',
props: {
// Not place at `children` to avoid the trailing whitespace be trim by formatter.
insert,
...attributes,
},
})),
};
}
@@ -0,0 +1,47 @@
import type { z } from 'zod';
import { SYS_KEYS } from '../consts.js';
import { native2Y } from '../reactive/index.js';
import type { BlockModel, BlockSchema } from '../schema/base.js';
import { internalPrimitives } from '../schema/base.js';
import type { YBlock } from '../store/doc/block/index.js';
import type { BlockProps } from '../store/doc/block-collection.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 FIXME: ts error
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 FIXME: ts error
model[key] = native2Y(value);
});
}
export const hash = (str: string) => {
return str
.split('')
.reduce(
(prevHash, currVal) =>
((prevHash << 5) - prevHash + currVal.charCodeAt(0)) | 0,
0
);
};
@@ -0,0 +1,147 @@
import type { BlockSuiteFlags } from '@blocksuite/global/types';
import { Slot } from '@blocksuite/global/utils';
import { type Signal, signal } from '@preact/signals-core';
import clonedeep from 'lodash.clonedeep';
import merge from 'lodash.merge';
import type { Awareness as YAwareness } from 'y-protocols/awareness.js';
import type { BlockCollection } from '../store/index.js';
export interface UserInfo {
name: string;
}
type UserSelection = Array<Record<string, unknown>>;
// Raw JSON state in awareness CRDT
export type RawAwarenessState<Flags extends BlockSuiteFlags = BlockSuiteFlags> =
{
user?: UserInfo;
color?: string;
flags: Flags;
// use v2 to avoid crush on old clients
selectionV2: Record<string, UserSelection>;
};
export interface AwarenessEvent<
Flags extends BlockSuiteFlags = BlockSuiteFlags,
> {
id: number;
type: 'add' | 'update' | 'remove';
state?: RawAwarenessState<Flags>;
}
export class AwarenessStore<Flags extends BlockSuiteFlags = BlockSuiteFlags> {
private _flags: Signal<Flags>;
private _onAwarenessChange = (diff: {
added: number[];
removed: number[];
updated: number[];
}) => {
this._flags.value = this.awareness.getLocalState()?.flags ?? {};
const { added, removed, updated } = diff;
const states = this.awareness.getStates();
added.forEach(id => {
this.slots.update.emit({
id,
type: 'add',
state: states.get(id),
});
});
updated.forEach(id => {
this.slots.update.emit({
id,
type: 'update',
state: states.get(id),
});
});
removed.forEach(id => {
this.slots.update.emit({
id,
type: 'remove',
});
});
};
readonly awareness: YAwareness<RawAwarenessState<Flags>>;
readonly slots = {
update: new Slot<AwarenessEvent<Flags>>(),
};
constructor(
awareness: YAwareness<RawAwarenessState<Flags>>,
defaultFlags: Flags
) {
this._flags = signal<Flags>(defaultFlags);
this.awareness = awareness;
this.awareness.on('change', this._onAwarenessChange);
this.awareness.setLocalStateField('selectionV2', {});
this._initFlags(defaultFlags);
}
private _initFlags(defaultFlags: Flags) {
const upstreamFlags = this.awareness.getLocalState()?.flags;
const flags = clonedeep(defaultFlags);
if (upstreamFlags) {
merge(flags, upstreamFlags);
}
this.awareness.setLocalStateField('flags', flags);
}
destroy() {
this.awareness.off('change', this._onAwarenessChange);
this.slots.update.dispose();
this.awareness.destroy();
}
getFlag<Key extends keyof Flags>(field: Key) {
return this._flags.value[field];
}
getLocalSelection(
selectionManagerId: string
): ReadonlyArray<Record<string, unknown>> {
return (
(this.awareness.getLocalState()?.selectionV2 ?? {})[selectionManagerId] ??
[]
);
}
getStates(): Map<number, RawAwarenessState<Flags>> {
return this.awareness.getStates();
}
isReadonly(blockCollection: BlockCollection): boolean {
const rd = this.getFlag('readonly');
if (rd && typeof rd === 'object') {
return Boolean((rd as Record<string, boolean>)[blockCollection.id]);
} else {
return false;
}
}
setFlag<Key extends keyof Flags>(field: Key, value: Flags[Key]) {
const oldFlags = this.awareness.getLocalState()?.flags ?? {};
this.awareness.setLocalStateField('flags', { ...oldFlags, [field]: value });
}
setLocalSelection(selectionManagerId: string, selection: UserSelection) {
const oldSelection = this.awareness.getLocalState()?.selectionV2 ?? {};
this.awareness.setLocalStateField('selectionV2', {
...oldSelection,
[selectionManagerId]: selection,
});
}
setReadonly(blockCollection: BlockCollection, value: boolean): void {
const flags = this.getFlag('readonly') ?? {};
this.setFlag('readonly', {
...flags,
[blockCollection.id]: value,
} as Flags['readonly']);
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { Transaction } from 'yjs';
import * as Y from 'yjs';
import { createYProxy } from '../reactive/proxy.js';
export type BlockSuiteDocAllowedValue =
| Record<string, unknown>
| unknown[]
| Y.Text;
export type BlockSuiteDocData = Record<string, BlockSuiteDocAllowedValue>;
export class BlockSuiteDoc extends Y.Doc {
private _spaces: Y.Map<Y.Doc> = this.getMap('spaces');
get spaces() {
return this._spaces;
}
getArrayProxy<
Key extends keyof BlockSuiteDocData & string,
Value extends unknown[] = BlockSuiteDocData[Key] extends unknown[]
? BlockSuiteDocData[Key]
: never,
>(key: Key): Value {
const array = super.getArray(key);
return createYProxy(array) as Value;
}
getMapProxy<
Key extends keyof BlockSuiteDocData & string,
Value extends Record<
string,
unknown
> = BlockSuiteDocData[Key] extends Record<string, unknown>
? BlockSuiteDocData[Key]
: never,
>(key: Key): Value {
const map = super.getMap(key);
return createYProxy(map);
}
override toJSON(): Record<string, any> {
const json = super.toJSON();
delete json.spaces;
const spaces: Record<string, unknown> = {};
this.spaces.forEach((doc, key) => {
spaces[key] = doc.toJSON();
});
return {
...json,
spaces,
};
}
override transact<T>(f: (arg0: Transaction) => T, origin?: number | string) {
return super.transact(f, origin);
}
}
@@ -0,0 +1,3 @@
export * from './awareness.js';
export * from './doc.js';
export * from './utils.js';
@@ -0,0 +1,7 @@
import type { Doc as YDoc } from 'yjs';
export type SubdocEvent = {
loaded: Set<YDoc>;
removed: Set<YDoc>;
added: Set<YDoc>;
};