mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 19:16:29 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -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';
|
||||
@@ -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']);
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
Reference in New Issue
Block a user