snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# `@blocksuite/store`
BlockSuite data store built for general purpose state management. Used in [AFFiNE](https://affine.pro/).
## Documentation
Checkout [blocksuite.io](https://blocksuite.io/) for comprehensive documentation.

View File

@@ -0,0 +1,42 @@
{
"name": "@blocksuite/store",
"description": "BlockSuite data store built for general purpose state management.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/global": "workspace:*",
"@blocksuite/sync": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@types/lodash.ismatch": "^4.4.9",
"file-type": "^21.0.0",
"lib0": "^0.2.114",
"lodash.ismatch": "^4.4.0",
"minimatch": "^10.2.5",
"nanoid": "^5.1.6",
"rxjs": "^7.8.2",
"y-protocols": "^1.0.6",
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
"./test": "./src/test/index.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__",
"shim.d.ts"
],
"version": "0.27.5"
}

View File

@@ -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,
}
`;

View File

@@ -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'
);
});
});

View File

@@ -0,0 +1,550 @@
import { computed, effect } from '@preact/signals-core';
import { describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import { BlockSchemaExtension } from '../extension/schema.js';
import {
Block,
BlockModel,
defineBlockSchema,
internalPrimitives,
} from '../model/block/index.js';
import type { YBlock } from '../model/block/types.js';
import type { Text } from '../reactive/text/index.js';
import { createAutoIncrementIdGenerator } from '../test/index.js';
import { TestWorkspace } from '../test/test-workspace.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,
},
});
const pageSchemaExtension = BlockSchemaExtension(pageSchema);
const tableSchema = defineBlockSchema({
flavour: 'table',
props: () => ({
cols: {} as Record<string, { color: string }>,
rows: [] as Array<{ color: string }>,
}),
metadata: {
role: 'content',
version: 1,
},
});
const tableSchemaExtension = BlockSchemaExtension(tableSchema);
const flatTableSchema = defineBlockSchema({
flavour: 'flat-table',
props: internal => ({
title: internal.Text(),
cols: { internal: { color: 'white' } } as Record<string, { color: string }>,
textCols: {} as Record<string, Text>,
rows: {} as Record<string, { color: string }>,
labels: [] as Array<string>,
optional: undefined as string | undefined,
}),
metadata: {
role: 'content',
version: 1,
isFlatData: true,
},
});
const flatTableSchemaExtension = BlockSchemaExtension(flatTableSchema);
class RootModel extends BlockModel<
ReturnType<(typeof pageSchema)['model']['props']>
> {}
class TableModel extends BlockModel<
ReturnType<(typeof tableSchema)['model']['props']>
> {}
class FlatTableModel extends BlockModel<
ReturnType<(typeof flatTableSchema)['model']['props']>
> {}
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
return { id: 'test-collection', idGenerator };
}
const defaultDocId = 'doc:home';
function createTestDoc(docId = defaultDocId) {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc(docId);
doc.load();
const store = doc.getStore({
extensions: [
pageSchemaExtension,
tableSchemaExtension,
flatTableSchemaExtension,
],
});
return store;
}
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.props.count).toBe(0);
expect(model.props.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.props.count$.value % 2 === 1);
expect(model.props.count$.value).toBe(0);
expect(isOdd.peek()).toBe(false);
// set prop
model.props.count = 1;
expect(model.props.count$.value).toBe(1);
expect(isOdd.peek()).toBe(true);
expect(yBlock.get('prop:count')).toBe(1);
// set signal
model.props.count$.value = 2;
expect(model.props.count).toBe(2);
expect(isOdd.peek()).toBe(false);
expect(yBlock.get('prop:count')).toBe(2);
// set prop
yBlock.set('prop:count', 3);
expect(model.props.count).toBe(3);
expect(model.props.count$.value).toBe(3);
expect(isOdd.peek()).toBe(true);
const toggleEffect = vi.fn();
effect(() => {
toggleEffect(model.props.toggle$.value);
});
expect(toggleEffect).toHaveBeenCalledTimes(1);
const runToggle = () => {
const next = !model.props.toggle;
model.props.toggle = next;
expect(model.props.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.props.toggle;
model.props.toggle$.value = next;
expect(model.props.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.props.style).toEqual({});
model.props.style = { color: 'red' };
expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({
color: 'red',
});
expect(model.props.style$.value).toEqual({ color: 'red' });
model.props.style.color = 'yellow';
expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({
color: 'yellow',
});
expect(model.props.style$.value).toEqual({ color: 'yellow' });
model.props.style$.value = { color: 'blue' };
expect(model.props.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.props.style.color).toBe('green');
expect(model.props.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 onChange = vi.fn();
const block = new Block(doc.schema, yBlock, doc, { onChange });
const model = block.model as RootModel;
expect(model.props.count).toBe(0);
model.stash('count');
onChange.mockClear();
model.props.count = 1;
expect(model.props.count$.value).toBe(1);
expect(yBlock.get('prop:count')).toBe(0);
expect(onChange).toHaveBeenCalledTimes(1);
model.props.count$.value = 2;
expect(model.props.count).toBe(2);
expect(yBlock.get('prop:count')).toBe(0);
expect(onChange).toHaveBeenCalledTimes(2);
model.pop('count');
expect(yBlock.get('prop:count')).toBe(2);
expect(model.props.count).toBe(2);
expect(model.props.count$.value).toBe(2);
expect(onChange).toHaveBeenCalledTimes(3);
model.stash('count');
yBlock.set('prop:count', 3);
expect(model.props.count).toBe(3);
expect(model.props.count$.value).toBe(3);
model.props.count$.value = 4;
expect(yBlock.get('prop:count')).toBe(3);
expect(model.props.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.props.title = internalPrimitives.Text('abc');
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'title', true);
expect(model.props.title$.value.toDelta()).toEqual([{ insert: 'abc' }]);
onPropsUpdated.mockClear();
model.props.title.insert('d', 1);
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'title', true);
expect(model.props.title$.value.toDelta()).toEqual([{ insert: 'adbc' }]);
onPropsUpdated.mockClear();
model.props.boxed.getValue()!.set('foo', 0);
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'boxed', true);
expect(model.props.boxed$.value.getValue()!.toJSON()).toEqual({
foo: 0,
});
});
test('deep sync', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'table');
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 TableModel;
expect(model.props.cols).toEqual({});
expect(model.props.rows).toEqual([]);
model.props.cols = {
'1': { color: 'red' },
};
const onColsUpdated = vi.fn();
const onRowsUpdated = vi.fn();
effect(() => {
onColsUpdated(model.props.cols$.value);
});
effect(() => {
onRowsUpdated(model.props.rows$.value);
});
const getColsMap = () => yBlock.get('prop:cols') as Y.Map<unknown>;
const getRowsArr = () => yBlock.get('prop:rows') as Y.Array<unknown>;
expect(getColsMap().toJSON()).toEqual({
'1': { color: 'red' },
});
expect(model.props.cols$.value).toEqual({
'1': { color: 'red' },
});
onPropsUpdated.mockClear();
onColsUpdated.mockClear();
model.props.cols['2'] = { color: 'blue' };
expect(getColsMap().toJSON()).toEqual({
'1': { color: 'red' },
'2': { color: 'blue' },
});
expect(onColsUpdated).toHaveBeenCalledWith({
'1': { color: 'red' },
'2': { color: 'blue' },
});
expect(onPropsUpdated).toHaveBeenCalledTimes(1);
expect(onColsUpdated).toHaveBeenCalledTimes(1);
onPropsUpdated.mockClear();
onColsUpdated.mockClear();
const map = new Y.Map();
map.set('color', 'green');
getColsMap().set('3', map);
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'cols', true);
expect(onColsUpdated).toHaveBeenCalledWith({
'1': { color: 'red' },
'2': { color: 'blue' },
'3': { color: 'green' },
});
expect(onPropsUpdated).toHaveBeenCalledTimes(1);
expect(onColsUpdated).toHaveBeenCalledTimes(1);
onPropsUpdated.mockClear();
onRowsUpdated.mockClear();
model.props.rows.push({ color: 'yellow' });
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'rows', true);
expect(onRowsUpdated).toHaveBeenCalledWith([{ color: 'yellow' }]);
expect(onPropsUpdated).toHaveBeenCalledTimes(1);
expect(onRowsUpdated).toHaveBeenCalledTimes(1);
onPropsUpdated.mockClear();
onRowsUpdated.mockClear();
const row1 = getRowsArr().get(0) as Y.Map<string>;
row1.set('color', 'green');
expect(onRowsUpdated).toHaveBeenCalledWith([{ color: 'green' }]);
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'rows', true);
expect(model.props.rows$.value).toEqual([{ color: 'green' }]);
expect(onPropsUpdated).toHaveBeenCalledTimes(1);
expect(onRowsUpdated).toHaveBeenCalledTimes(1);
});
describe('flat', () => {
test('flat crud', async () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'flat-table');
yBlock.set('sys:children', new Y.Array());
const onChange = vi.fn();
const onColUpdated = vi.fn();
const block = new Block(doc.schema, yBlock, doc, { onChange });
const model = block.model as FlatTableModel;
model.props.title = internalPrimitives.Text();
model.props.cols$.subscribe(onColUpdated);
onChange.mockClear();
onColUpdated.mockClear();
model.props.cols = {
...model.props.cols,
a: { color: 'red' },
};
expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(yBlock.get('prop:cols.a.color')).toBe('red');
expect(yBlock.get('prop:cols.internal.color')).toBe('white');
expect(onChange).toHaveBeenCalledTimes(1);
onChange.mockClear();
onColUpdated.mockClear();
model.props.cols.b = { color: 'blue' };
expect(yBlock.get('prop:cols.b.color')).toBe('blue');
expect(model.props.cols$.peek()).toEqual({
a: { color: 'red' },
b: { color: 'blue' },
internal: { color: 'white' },
});
expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'cols', true);
model.props.cols.a.color = 'black';
expect(yBlock.get('prop:cols.a.color')).toBe('black');
expect(model.props.cols$.value.a.color).toBe('black');
onChange.mockClear();
onColUpdated.mockClear();
model.props.cols$.value = {
a: { color: 'red' },
};
expect(yBlock.get('prop:cols.a.color')).toBe('red');
expect(yBlock.get('prop:cols.internal.color')).toBe(undefined);
expect(yBlock.get('prop:cols.b.color')).toBe(undefined);
expect(model.props.cols).toEqual({
a: { color: 'red' },
});
expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'cols', true);
onChange.mockClear();
onColUpdated.mockClear();
delete (model.props.cols.a as Record<string, unknown>).color;
expect(yBlock.get('prop:cols.a.color')).toBe(undefined);
expect(model.props.cols.a).toEqual({});
expect(model.props.cols$.value).toEqual({ a: {} });
expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'cols', true);
model.props.cols = {
a: { color: 'red' },
b: { color: 'blue' },
};
onChange.mockClear();
onColUpdated.mockClear();
delete (model.props as Record<string, unknown>).cols;
expect(model.props.cols$.value).toBeUndefined();
expect(yBlock.get('prop:cols.a.color')).toBe(undefined);
expect(yBlock.get('prop:cols.b.color')).toBe(undefined);
expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1);
onChange.mockClear();
model.props.title.insert('test', 0);
expect((yBlock.get('prop:title') as Y.Text).toJSON()).toBe('test');
expect(model.props.title$.value.toDelta()).toEqual([{ insert: 'test' }]);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'title', true);
onChange.mockClear();
model.props.labels.push('test');
const getLabels = () => yBlock.get('prop:labels') as Y.Array<unknown>;
expect(getLabels().toJSON()).toEqual(['test']);
expect(model.props.labels$.value).toEqual(['test']);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'labels', true);
onChange.mockClear();
model.props.labels$.value = ['test2'];
expect(getLabels().toJSON()).toEqual(['test2']);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'labels', true);
onChange.mockClear();
model.props.labels.splice(0, 1);
expect(getLabels().toJSON()).toEqual([]);
expect(model.props.labels$.value).toEqual([]);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'labels', true);
model.props.textCols = {
a: internalPrimitives.Text(),
};
onChange.mockClear();
model.props.textCols.a.insert('test', 0);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'textCols', true);
expect((yBlock.get('prop:textCols.a') as Y.Text).toJSON()).toBe('test');
expect(model.props.textCols$.value.a.toDelta()).toEqual([
{ insert: 'test' },
]);
onChange.mockClear();
expect(model.props).not.toHaveProperty('optional');
expect(model.props).toHaveProperty('optional$');
model.props.optional$.value = 'test';
expect(model.props.optional).toBe('test');
expect(model.props.optional$.value).toBe('test');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'optional', true);
expect(yBlock.get('prop:optional')).toBe('test');
});
test('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', 'flat-table');
yBlock.set('sys:children', new Y.Array());
const onColUpdated = vi.fn();
const onChange = vi.fn();
const block = new Block(doc.schema, yBlock, doc, { onChange });
const model = block.model as FlatTableModel;
model.props.cols$.subscribe(onColUpdated);
onChange.mockClear();
onColUpdated.mockClear();
model.props.cols = {
a: { color: 'red' },
};
expect(yBlock.get('prop:cols.a.color')).toBe('red');
expect(model.props.cols$.value.a.color).toBe('red');
expect(onColUpdated).toHaveBeenCalledTimes(1);
onChange.mockClear();
onColUpdated.mockClear();
model.stash('cols');
model.props.cols.a.color = 'blue';
expect(yBlock.get('prop:cols.a.color')).toBe('red');
expect(model.props.cols$.value.a.color).toBe('blue');
expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1);
model.pop('cols');
expect(yBlock.get('prop:cols.a.color')).toBe('blue');
expect(onColUpdated).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,850 @@
import type { Subject } from 'rxjs';
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyUpdate, type Doc, encodeStateAsUpdate } from 'yjs';
import type { BlockModel, DocMeta, Store } from '../index.js';
import { Text } from '../reactive/text/index.js';
import { createAutoIncrementIdGenerator } from '../test/index.js';
import { TestWorkspace } from '../test/test-workspace.js';
import {
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from './test-schema.js';
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
return { id: 'test-collection', idGenerator };
}
const defaultDocId = 'doc:home';
const spaceId = defaultDocId;
const spaceMetaId = 'meta';
function serializCollection(doc: Doc): Record<string, any> {
const spaces = {};
doc.getMap('spaces').forEach((subDoc, key) => {
// @ts-expect-error ignore
spaces[key] = subDoc.toJSON();
});
const json = doc.toJSON();
delete json.spaces;
return {
...json,
spaces,
};
}
function waitOnce<T>(slot: Subject<T>) {
return new Promise<T>(resolve => {
const subscription = slot.subscribe(val => {
subscription.unsubscribe();
resolve(val);
});
});
}
function createRoot(doc: Store) {
doc.addBlock('affine:page');
if (!doc.root) throw new Error('root not found');
return doc.root;
}
const extensions = [
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
];
function createTestDoc(docId = defaultDocId) {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc(docId);
const store = doc.getStore({
extensions,
});
doc.load();
return store;
}
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 TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('doc:home');
doc.load();
const actual = serializCollection(collection.doc);
const actualDoc = actual[spaceMetaId].pages[0] as DocMeta;
assert.equal(typeof actualDoc.createDate, 'number');
// @ts-expect-error ignore
delete actualDoc.createDate;
assert.deepEqual(actual, {
[spaceMetaId]: {
pages: [
{
id: 'doc:home',
title: '',
tags: [],
},
],
},
spaces: {
[spaceId]: {
blocks: {},
},
},
});
});
it('init collection with custom id generator', () => {
const options = createTestOptions();
let id = 100;
const collection = new TestWorkspace({
...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 TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('space:0');
const store = doc.getStore({
extensions,
});
const readyCallback = vi.fn();
const rootAddedCallback = vi.fn();
store.slots.ready.subscribe(readyCallback);
store.slots.rootAdded.subscribe(rootAddedCallback);
store.load(() => {
const rootId = store.addBlock('affine:page', {
title: new Text(),
});
expect(rootAddedCallback).toBeCalledTimes(1);
store.addBlock('affine:note', {}, rootId);
});
expect(readyCallback).toBeCalledTimes(1);
});
it('collection docs with yjs applyUpdate', () => {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const collection2 = new TestWorkspace(options);
const doc = collection.createDoc('space:0');
const store = doc.getStore({
extensions,
});
doc.load(() => {
store.addBlock('affine:page', {
title: new 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(serializCollection(collection2.doc)['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');
if (!doc2) {
throw new Error('doc2 is not found');
}
applyUpdate(doc2.spaceDoc, update);
expect(serializCollection(collection2.doc)['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 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 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 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 Text(),
})
);
const blockId = await waitOnce(doc.slots.rootAdded);
const block = doc.getModelById(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 TestWorkspace(options);
collection.meta.initialize();
const doc0 = collection.createDoc('doc:home');
const doc1 = collection.createDoc('space:doc1');
// oxlint-disable-next-line typescript/await-thenable
await Promise.all([doc0.load(), doc1.load()]);
assert.equal(collection.docs.size, 2);
const store0 = doc0.getStore({
extensions,
});
store0.addBlock('affine:page', {
title: new 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 TestWorkspace(options);
collection.meta.initialize();
const doc0 = collection.createDoc('doc:home');
collection.removeDoc(doc0.id);
assert.equal(collection.docs.size, 0);
});
it('can set doc state', () => {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
collection.createDoc('doc:home');
assert.deepEqual(
collection.meta.docMetas.map(({ id, title }) => ({
id,
title,
})),
[
{
id: 'doc:home',
title: '',
},
]
);
let called = false;
collection.slots.docListUpdated.subscribe(() => {
called = true;
});
collection.meta.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);
});
});
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.getModelById('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.getModelById('2') as BlockModel;
const deletedModelParent = doc.getModelById('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.getModelById('2') as BlockModel;
const moveToModel = doc.getModelById('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.getModelById('3') as BlockModel;
assert.equal(text.flavour, 'affine:paragraph');
assert.equal(rootModel.children[0].children.indexOf(text), 1);
const invalid = doc.getModelById('😅');
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);
});
});

View File

@@ -0,0 +1,324 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import type { BlockModel, Store } from '../model/index.js';
import { createAutoIncrementIdGenerator } from '../test/index.js';
import { TestWorkspace } from '../test/test-workspace.js';
import {
DividerBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
type RootBlockModel,
RootBlockSchemaExtension,
} from './test-schema.js';
const extensions = [
RootBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
DividerBlockSchemaExtension,
];
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
return { id: 'test-collection', idGenerator };
}
test('trigger props updated', () => {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('home');
doc.load();
const store = doc.getStore({
extensions,
});
store.addBlock('affine:page');
const rootModel = store.root as RootBlockModel;
expect(rootModel).not.toBeNull();
const onPropsUpdated = vi.fn();
rootModel.propsUpdated.subscribe(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.props.count = 1;
expect(onPropsUpdated).toBeCalledTimes(1);
expect(onPropsUpdated).toHaveBeenNthCalledWith(1, { key: 'count' });
expect(getCount()).toBe(1);
rootModel.props.count = 2;
expect(onPropsUpdated).toBeCalledTimes(2);
expect(onPropsUpdated).toHaveBeenNthCalledWith(2, { key: 'count' });
expect(getCount()).toBe(2);
rootModel.props.style.color = 'blue';
expect(onPropsUpdated).toBeCalledTimes(3);
expect(onPropsUpdated).toHaveBeenNthCalledWith(3, { key: 'style' });
expect(getColor()).toBe('blue');
rootModel.props.style = { color: 'red' };
expect(onPropsUpdated).toBeCalledTimes(4);
expect(onPropsUpdated).toHaveBeenNthCalledWith(4, { key: 'style' });
expect(getColor()).toBe('red');
rootModel.props.style.color = 'green';
expect(onPropsUpdated).toBeCalledTimes(5);
expect(onPropsUpdated).toHaveBeenNthCalledWith(5, { key: 'style' });
expect(getColor()).toBe('green');
rootModel.props.items.push(1);
expect(onPropsUpdated).toBeCalledTimes(6);
expect(onPropsUpdated).toHaveBeenNthCalledWith(6, { key: 'items' });
expect(getItems().get(0)).toBe(1);
rootModel.props.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 TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('home');
doc.load();
const store = doc.getStore({
extensions,
});
store.addBlock('affine:page');
const rootModel = store.root as RootBlockModel;
expect(rootModel).not.toBeNull();
const onPropsUpdated = vi.fn();
rootModel.propsUpdated.subscribe(onPropsUpdated);
const getCount = () => rootModel.yBlock.get('prop:count');
const getColor = () =>
(rootModel.yBlock.get('prop:style') as Y.Map<string>).get('color');
rootModel.props.count = 1;
expect(onPropsUpdated).toBeCalledTimes(1);
expect(onPropsUpdated).toHaveBeenNthCalledWith(1, { key: 'count' });
expect(getCount()).toBe(1);
rootModel.stash('count');
rootModel.props.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.props.style.color = 'blue';
expect(getColor()).toBe('blue');
expect(onPropsUpdated).toBeCalledTimes(5);
expect(onPropsUpdated).toHaveBeenNthCalledWith(5, { key: 'style' });
rootModel.stash('style');
rootModel.props.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.props.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 TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('home');
doc.load();
const store = doc.getStore({
extensions,
});
store.addBlock('affine:page');
const rootModel = store.root as RootBlockModel;
expect(rootModel).not.toBeNull();
let value: unknown;
rootModel.propsUpdated.subscribe(({ key }) => {
// @ts-expect-error ignore
value = rootModel.props[key];
});
rootModel.props.count = 1;
expect(value).toBe(1);
rootModel.stash('count');
rootModel.props.count = 2;
expect(value).toBe(2);
rootModel.pop('count');
rootModel.props.count = 3;
expect(value).toBe(3);
rootModel.props.style.color = 'blue';
expect(value).toEqual({ color: 'blue' });
rootModel.stash('style');
rootModel.props.style = { color: 'red' };
expect(value).toEqual({ color: 'red' });
rootModel.props.style.color = 'green';
expect(value).toEqual({ color: 'green' });
rootModel.pop('style');
rootModel.props.style.color = 'yellow';
expect(value).toEqual({ color: 'yellow' });
});
test('query', () => {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('home');
doc.load();
const store1 = doc.getStore({
extensions,
});
const store2 = doc.getStore({
extensions,
});
const store3 = doc.getStore({
extensions,
query: {
mode: 'loose',
match: [
{
flavour: 'affine:list',
viewType: 'hidden',
},
],
},
});
expect(store1).toBe(store2);
expect(store1).not.toBe(store3);
const page = store1.addBlock('affine:page');
const note = store1.addBlock('affine:note', {}, page);
const paragraph1 = store1.addBlock('affine:paragraph', {}, note);
const list1 = store1.addBlock('affine:list' as never, {}, note);
expect(store2?.getBlock(paragraph1)?.blockViewType).toBe('display');
expect(store2?.getBlock(list1)?.blockViewType).toBe('display');
expect(store3?.getBlock(list1)?.blockViewType).toBe('hidden');
const list2 = store1.addBlock('affine:list' as never, {}, note);
expect(store2?.getBlock(list2)?.blockViewType).toBe('display');
expect(store3?.getBlock(list2)?.blockViewType).toBe('hidden');
});
test('local readonly', () => {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('home');
const store1 = doc.getStore({
extensions,
});
const store2 = doc.getStore({
readonly: true,
extensions,
});
const store3 = doc.getStore({ readonly: false, extensions });
expect(store1.readonly).toBeFalsy();
expect(store2.readonly).toBeTruthy();
expect(store3.readonly).toBeFalsy();
store1.readonly = true;
expect(store1.readonly).toBeTruthy();
expect(store2.readonly).toBeTruthy();
expect(store3.readonly).toBeFalsy();
store1.readonly = false;
expect(store1.readonly).toBeFalsy();
expect(store2.readonly).toBeTruthy();
expect(store3.readonly).toBeFalsy();
});
describe('move blocks', () => {
type Context = { doc: Store; page: BlockModel; notes: BlockModel[] };
beforeEach((context: Context) => {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('home');
doc.load();
const store = doc.getStore({ extensions });
const pageId = store.addBlock('affine:page');
const page = store.getBlock(pageId)!.model;
const noteIds = store.addBlocks(
[1, 2, 3].map(i => ({
flavour: 'affine:note',
blockProps: { id: `${i}` },
})),
page
);
const notes = noteIds.map(id => store.getBlock(id)!.model);
context.doc = store;
context.page = page;
context.notes = notes;
});
test('move block to itself', ({ doc, page, notes }: Context) => {
const noteIds = notes.map(({ id }) => id);
doc.moveBlocks([notes[0]], page, notes[0], true);
expect(page.children.map(({ id }) => id)).toEqual(noteIds);
doc.moveBlocks([notes[0]], page, notes[0], false);
expect(page.children.map(({ id }) => id)).toEqual(noteIds);
});
});

View File

@@ -0,0 +1,186 @@
import { literal } from 'lit/static-html.js';
import { describe, expect, it, vi } from 'vitest';
import { BlockSchemaExtension } from '../extension/schema.js';
import { defineBlockSchema } from '../model/block/zod.js';
// import some blocks
import { SchemaValidateError } from '../schema/error.js';
import { createAutoIncrementIdGenerator } from '../test/index.js';
import { TestWorkspace } from '../test/test-workspace.js';
import {
DividerBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from './test-schema.js';
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
return { id: 'test-collection', idGenerator };
}
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 TestCustomNoteBlockSchemaExtension = BlockSchemaExtension(
TestCustomNoteBlockSchema
);
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 TestInvalidNoteBlockSchemaExtension = BlockSchemaExtension(
TestInvalidNoteBlockSchema
);
const TestRoleBlockSchema = defineBlockSchema({
flavour: 'affine:note-block-role-test',
metadata: {
version: 1,
role: 'content',
parent: ['affine:note'],
children: ['@test'],
},
props: internal => ({
text: internal.Text(),
}),
});
const TestRoleBlockSchemaExtension = BlockSchemaExtension(TestRoleBlockSchema);
const TestParagraphBlockSchema = defineBlockSchema({
flavour: 'affine:test-paragraph',
metadata: {
version: 1,
role: 'test',
parent: ['@content'],
},
});
const TestParagraphBlockSchemaExtension = BlockSchemaExtension(
TestParagraphBlockSchema
);
const extensions = [
RootBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
DividerBlockSchemaExtension,
TestCustomNoteBlockSchemaExtension,
TestInvalidNoteBlockSchemaExtension,
TestRoleBlockSchemaExtension,
TestParagraphBlockSchemaExtension,
];
const defaultDocId = 'doc0';
function createTestDoc(docId = defaultDocId) {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc(docId);
doc.load();
const store = doc.getStore({ extensions });
return store;
}
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;
});
});
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 roleId = doc.addBlock('affine:note-block-role-test', {}, noteId);
doc.addBlock('affine:paragraph', {}, roleId);
doc.addBlock('affine:paragraph', {}, roleId);
expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => {
return call[0] instanceof SchemaValidateError;
});
consoleMock.mockClear();
doc.addBlock('affine:test-paragraph', {}, roleId);
doc.addBlock('affine:test-paragraph', {}, roleId);
expect(consoleMock).not.toBeCalled();
expect(doc.getBlocksByFlavour('affine:test-paragraph')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,103 @@
import { BlockSchemaExtension } from '../extension/schema.js';
import { BlockModel, defineBlockSchema } from '../model/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 const RootBlockSchemaExtension = BlockSchemaExtension(RootBlockSchema);
export class RootBlockModel extends BlockModel<
ReturnType<(typeof RootBlockSchema)['model']['props']>
> {}
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 NoteBlockSchemaExtension = BlockSchemaExtension(NoteBlockSchema);
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 ParagraphBlockSchemaExtension =
BlockSchemaExtension(ParagraphBlockSchema);
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 ListBlockSchemaExtension = BlockSchemaExtension(ListBlockSchema);
export const DividerBlockSchema = defineBlockSchema({
flavour: 'affine:divider',
metadata: {
version: 1,
role: 'content',
children: [],
},
});
export const DividerBlockSchemaExtension =
BlockSchemaExtension(DividerBlockSchema);

View File

@@ -0,0 +1,115 @@
import type { TestWorkspace } from '../test';
declare global {
interface WindowEventMap {
'test-result': CustomEvent<TestResult>;
}
interface Window {
collection: TestWorkspace;
}
}
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 = [];
}
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;
});
});
});
}

View File

@@ -0,0 +1,138 @@
import { expect, test } from 'vitest';
import * as Y from 'yjs';
import { MemoryBlobCRUD } from '../adapter/index.js';
import { BlockSchemaExtension } from '../extension/schema.js';
import { BlockModel } from '../model/block/block-model.js';
import { defineBlockSchema } from '../model/block/zod.js';
import { Text } from '../reactive/index.js';
import { createAutoIncrementIdGenerator } from '../test/index.js';
import { TestWorkspace } from '../test/test-workspace.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,
},
});
const docSchemaExtension = BlockSchemaExtension(docSchema);
class RootBlockModel extends BlockModel<
ReturnType<(typeof docSchema)['model']['props']>
> {}
const extensions = [docSchemaExtension];
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
return { id: 'test-collection', idGenerator };
}
const transformer = new BaseBlockTransformer(new Map());
const blobCRUD = new MemoryBlobCRUD();
const assets = new AssetsManager({ blob: blobCRUD });
test('model to snapshot', () => {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('home');
const store = doc.getStore({ extensions });
doc.load();
store.addBlock('page');
const rootModel = store.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 TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc('home');
const store = doc.getStore({ extensions });
doc.load();
store.addBlock('page');
const rootModel = store.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 ignore
expect(model.props.title).toBeInstanceOf(Text);
// @ts-expect-error ignore
map.set('title', model.props.title.yText);
// @ts-expect-error ignore
expect(model.props.title.toString()).toBe('doc title');
// @ts-expect-error ignore
expect(model.props.style).toEqual({
color: 'red',
});
// @ts-expect-error ignore
expect(model.props.count).toBe(3);
// @ts-expect-error ignore
expect(model.props.items).toMatchObject([
{
id: 0,
},
{
id: 1,
},
{
id: 2,
},
]);
// @ts-expect-error ignore
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}`);
});
});

View File

@@ -0,0 +1,278 @@
import { Subject } from 'rxjs';
import { describe, expect, test } from 'vitest';
import * as Y from 'yjs';
import { ReactiveFlatYMap } from '../reactive/flat-native-y/index.js';
import type { Text } from '../reactive/index.js';
import { Boxed, createYProxy, popProp, stashProp } from '../reactive/index.js';
describe('array', () => {
test('push and splice', () => {
const ydoc = new Y.Doc();
const arr = ydoc.getArray('arr');
arr.push([0, 1, 2, 3, 4, 5]);
const proxy = createYProxy(arr) as unknown[];
expect(arr.toJSON()).toEqual([0, 1, 2, 3, 4, 5]);
expect(proxy).toEqual([0, 1, 2, 3, 4, 5]);
proxy.push(6);
expect(arr.toJSON()).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(proxy).toEqual([0, 1, 2, 3, 4, 5, 6]);
proxy.splice(1, 1);
expect(arr.toJSON()).toEqual([0, 2, 3, 4, 5, 6]);
expect(proxy).toEqual([0, 2, 3, 4, 5, 6]);
proxy[0] = 2;
expect(arr.toJSON()).toEqual([2, 2, 3, 4, 5, 6]);
expect(proxy).toEqual([2, 2, 3, 4, 5, 6]);
});
test('shift and unshift', () => {
const ydoc = new Y.Doc();
const arr = ydoc.getArray('arr');
arr.push([0, 1, 2, 3, 4, 5]);
const proxy = createYProxy(arr) as unknown[];
expect(arr.toJSON()).toEqual([0, 1, 2, 3, 4, 5]);
// Test shift
const shifted = proxy.shift();
expect(shifted).toBe(0);
expect(arr.toJSON()).toEqual([1, 2, 3, 4, 5]);
expect(proxy).toEqual([1, 2, 3, 4, 5]);
const shifted2 = proxy.shift();
expect(shifted2).toBe(1);
expect(arr.toJSON()).toEqual([2, 3, 4, 5]);
expect(proxy).toEqual([2, 3, 4, 5]);
// Test shift on empty array
const emptyArr = ydoc.getArray('empty');
const emptyProxy = createYProxy(emptyArr) as unknown[];
const emptyShifted = emptyProxy.shift();
expect(emptyShifted).toBeUndefined();
expect(emptyArr.toJSON()).toEqual([]);
expect(emptyProxy).toEqual([]);
// Test unshift
proxy.unshift(-1, 0, 1);
expect(arr.toJSON()).toEqual([-1, 0, 1, 2, 3, 4, 5]);
expect(proxy).toEqual([-1, 0, 1, 2, 3, 4, 5]);
// Test unshift with multiple items
proxy.unshift(-3, -2);
expect(arr.toJSON()).toEqual([-3, -2, -1, 0, 1, 2, 3, 4, 5]);
expect(proxy).toEqual([-3, -2, -1, 0, 1, 2, 3, 4, 5]);
});
});
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 ignore
expect(map.get('obj2').get('bar').get('num')).toBe(3);
proxy.obj2.bar.str = 'hello';
// @ts-expect-error ignore
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 ignore
expect(map.get('inner').get('native').get('value')).toEqual([
'hello',
'world',
'foo',
]);
const native2 = new Boxed(0);
proxy.inner.native2 = native2;
// @ts-expect-error ignore
expect(map.get('inner').get('native2').get('value')).toBe(0);
native2.setValue(1);
// @ts-expect-error ignore
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);
});
});
test('flat', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
map.set('prop:col.a', 0);
map.set('prop:col.b', 1);
map.set('prop:col.c.d', 3);
map.set('prop:col.c.e', 4);
const reactive = new ReactiveFlatYMap(map, new Subject());
const proxy = reactive.proxy as Record<string, any>;
proxy.col.c.d = 200;
expect(map.get('prop:col.c.d')).toBe(200);
proxy.col.a = 200;
expect(map.get('prop:col.a')).toBe(200);
proxy.col.f = { a: 1 };
expect(map.get('prop:col.f.a')).toBe(1);
proxy.foo = 'foo';
expect(map.get('prop:foo')).toBe('foo');
proxy.col.c = {
d: 500,
};
expect(map.get('prop:col.c.d')).toBe(500);
expect(map.get('prop:col.c.e')).toBe(undefined);
proxy.col.a = 100;
expect(map.get('prop:col.a')).toBe(100);
proxy.col.c.e = 100;
expect(map.get('prop:col.c.e')).toBe(100);
const undoManager = new Y.UndoManager([map]);
ydoc.transact(() => {
map.set('prop:col.c.e', 200);
}, undoManager);
expect(proxy.col.c.e).toBe(200);
ydoc.transact(() => {
map.delete('prop:col.c.d');
}, undoManager);
expect(proxy.col.c.d).toBe(undefined);
proxy.foo$.value = 'foo2';
expect(map.get('prop:foo')).toBe('foo2');
proxy.col$.value = {
a: 10,
b: 20,
c: {
d: 30,
},
};
expect(map.get('prop:col.a')).toBe(10);
expect(map.get('prop:col.b')).toBe(20);
expect(map.get('prop:col.c.d')).toBe(30);
});

View File

@@ -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)}`;
}

View File

@@ -0,0 +1,324 @@
import type { ServiceProvider } from '@blocksuite/global/di';
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import {
BlockModel,
type DraftModel,
type Store,
toDraftModel,
} from '../model/index.js';
import type { AssetsManager } from '../transformer/assets.js';
import type { Slice, Transformer } 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: Transformer;
get configs() {
return this.job.adapterConfigs;
}
constructor(
job: Transformer,
readonly provider: ServiceProvider
) {
this.job = job;
}
async fromBlock(model: BlockModel | DraftModel) {
try {
const draftModel =
model instanceof BlockModel ? toDraftModel(model) : model;
const blockSnapshot = this.job.blockToSnapshot(draftModel);
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: Store) {
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: Store,
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: Store,
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 readonly _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) {
if (this._leave) {
await this._leave(o, this.context);
}
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 readonly 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>();
}
}

View File

@@ -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 readonly _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;
}
}

View File

@@ -0,0 +1,3 @@
export * from './assets.js';
export * from './base.js';
export * from './context.js';

View File

@@ -0,0 +1,7 @@
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', 'version']);

View File

@@ -0,0 +1,134 @@
import type { Container } from '@blocksuite/global/di';
/**
* # Understanding Extensions
*
* Extensions provide a way to extend the functionality of a system using dependency injection.
* They allow you to register services, implementations, and factories in the DI container,
* which can then be retrieved and used by different parts of the application.
*
* Extensions are particularly useful for:
* - Registering different implementations for different types
* - Creating pluggable architecture where components can be added or removed
* - Managing dependencies between different parts of the application
*
* # Usage Example: Fruit Processing System
*
* Let's consider a fruit processing system where different types of fruits need
* different processing methods. We'll show how to implement this using extensions.
*
* ## Step 1: Define the interfaces
*
* ```ts
* interface FruitProcessor {
* process(fruit: Fruit): void;
* }
*
* interface Fruit {
* type: string;
* // other properties
* }
* ```
*
* ## Step 2: Create a service identifier
*
* ```ts
* import { createIdentifier } from '@blocksuite/global/di';
*
* const FruitProcessorProvider = createIdentifier<FruitProcessor>('fruit-processor-provider');
* ```
*
* ## Step 3: Create implementations
*
* ```ts
* class AppleProcessor implements FruitProcessor {
* process(fruit: Fruit): void {
* console.log('Slicing apple');
* // Apple-specific processing
* }
* }
*
* class BananaProcessor implements FruitProcessor {
* process(fruit: Fruit): void {
* console.log('Peeling banana');
* // Banana-specific processing
* }
* }
* ```
*
* ## Step 4: Create an extension factory
*
* ```ts
* const FruitProcessorExtension = (
* fruitType: string,
* implementation: new () => FruitProcessor
* ): ExtensionType => {
* return {
* setup: di => {
* di.addImpl(FruitProcessorProvider(fruitType), implementation);
* }
* };
* };
* ```
*
* ## Step 5: Create concrete extensions
*
* ```ts
* export const AppleProcessorExtension = FruitProcessorExtension('apple', AppleProcessor);
* export const BananaProcessorExtension = FruitProcessorExtension('banana', BananaProcessor);
* ```
*
* ## Step 6: Use the extensions
*
* ```ts
* import { Container } from '@blocksuite/global/di';
*
* class FruitProcessingSystem {
* provider: ServiceProvider;
*
* constructor(extensions: ExtensionType[]) {
* const container = new Container();
*
* // Set up all extensions
* extensions.forEach(ext => ext.setup(container));
*
* // Create a provider from the container
* this.provider = container.provider();
* }
*
* processFruit(fruit: Fruit) {
* // Get the appropriate processor based on fruit type
* const processor = this.provider.get(FruitProcessorProvider(fruit.type));
*
* // Process the fruit
* processor.process(fruit);
* }
* }
*
* // Initialize the system with extensions
* const system = new FruitProcessingSystem([
* AppleProcessorExtension,
* BananaProcessorExtension
* ]);
*
* // Use the system
* system.processFruit({ type: 'apple' }); // Output: Slicing apple
* system.processFruit({ type: 'banana' }); // Output: Peeling banana
* ```
*
* Note: We deliberately used a non-block specific example here. In BlockSuite, the extension
* pattern can be applied to any entity that can be configured by third parties, not just blocks.
* This includes different tools in the whiteboard, different column types in database blocks,
* and many other extensible components. The pattern remains the same regardless of what you're extending.
*
* @category Extension
*/
export abstract class Extension {
static setup(_di: Container): void {
// do nothing
}
}
export interface ExtensionType {
setup(di: Container): void;
}

View File

@@ -0,0 +1,79 @@
import { signal } from '@preact/signals-core';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import type { Store } from '../../model';
import { StoreExtension } from '../store-extension';
export class HistoryExtension extends StoreExtension {
static override readonly key = 'history';
private readonly _history!: Y.UndoManager;
private readonly _canRedo = signal(false);
private readonly _canUndo = signal(false);
readonly onUpdated = new Subject<void>();
constructor(store: Store) {
super(store);
this._history = new Y.UndoManager([this.store.doc.yBlocks], {
trackedOrigins: new Set([this.store.doc.spaceDoc.clientID]),
});
}
private readonly _updateCanUndoRedoSignals = () => {
const canRedo = this._history.canRedo();
const canUndo = this._history.canUndo();
if (this._canRedo.peek() !== canRedo) {
this._canRedo.value = canRedo;
}
if (this._canUndo.peek() !== canUndo) {
this._canUndo.value = canUndo;
}
};
get canRedo() {
return this._canRedo.peek();
}
get canUndo() {
return this._canUndo.peek();
}
get canRedo$() {
return this._canRedo;
}
get canUndo$() {
return this._canUndo;
}
get undoManager() {
return this._history;
}
override loaded() {
this._updateCanUndoRedoSignals();
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);
}
private readonly _historyObserver = () => {
this._updateCanUndoRedoSignals();
this.onUpdated.next();
};
override disposed() {
super.disposed();
this._history.off('stack-cleared', this._historyObserver);
this._history.off('stack-item-added', this._historyObserver);
this._history.off('stack-item-popped', this._historyObserver);
this._history.off('stack-item-updated', this._historyObserver);
this.onUpdated.complete();
}
}

View File

@@ -0,0 +1 @@
export * from './history-extension';

View File

@@ -0,0 +1,6 @@
export * from './extension';
export * from './history';
export * from './schema';
export * from './selection';
export * from './store-extension';
export * from './workspace';

View File

@@ -0,0 +1,20 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { BlockSchemaType } from '../model/block/zod';
import type { ExtensionType } from './extension';
export const BlockSchemaIdentifier =
createIdentifier<BlockSchemaType>('BlockSchema');
export function BlockSchemaExtension(
blockSchema: BlockSchemaType
): ExtensionType {
return {
setup: di => {
di.addImpl(
BlockSchemaIdentifier(blockSchema.model.flavour),
() => blockSchema
);
},
};
}

View File

@@ -0,0 +1,46 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { SelectionConstructor } from './types';
export type BaseSelectionOptions = {
blockId: string;
};
export abstract class BaseSelection {
static readonly group: string;
static readonly type: string;
static readonly recoverable: boolean = false;
readonly blockId: string;
get group(): string {
return (this.constructor as SelectionConstructor).group;
}
get type(): string {
return (this.constructor as SelectionConstructor).type as string;
}
constructor({ blockId }: BaseSelectionOptions) {
this.blockId = blockId;
}
static fromJSON(_: Record<string, unknown>): BaseSelection {
throw new BlockSuiteError(
ErrorCode.SelectionError,
'You must override this method'
);
}
abstract equals(other: BaseSelection): boolean;
is<T extends SelectionConstructor>(
type: T
): this is T extends SelectionConstructor<infer U> ? U : never {
return this.type === type.type;
}
abstract toJSON(): Record<string, unknown>;
}

View File

@@ -0,0 +1,17 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '../extension';
import type { SelectionConstructor } from './types';
export const SelectionIdentifier =
createIdentifier<SelectionConstructor>('Selection');
export function SelectionExtension(
selectionCtor: SelectionConstructor
): ExtensionType {
return {
setup: di => {
di.addImpl(SelectionIdentifier(selectionCtor.type), () => selectionCtor);
},
};
}

View File

@@ -0,0 +1,4 @@
export * from './base';
export * from './identifier';
export * from './selection-extension';
export * from './types';

View File

@@ -0,0 +1,189 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { computed, signal } from '@preact/signals-core';
import { Subject } from 'rxjs';
import { nanoid } from '../../utils/id-generator';
import type { StackItem } from '../../yjs';
import { StoreExtension } from '../store-extension';
import type { BaseSelection } from './base';
import { SelectionIdentifier } from './identifier';
import type { SelectionConstructor } from './types';
export class StoreSelectionExtension extends StoreExtension {
static override readonly key = 'selection';
private readonly _id = `${this.store.id}:${nanoid()}`;
private _selectionConstructors: Record<string, SelectionConstructor> = {};
private readonly _selections = signal<BaseSelection[]>([]);
private readonly _remoteSelections = signal<Map<number, BaseSelection[]>>(
new Map()
);
private readonly _itemAdded = (event: { stackItem: StackItem }) => {
event.stackItem.meta.set('selection-state', this._selections.value);
};
private readonly _itemPopped = (event: { stackItem: StackItem }) => {
const selection = event.stackItem.meta.get('selection-state');
if (selection) {
this.set(selection as BaseSelection[]);
}
};
private readonly _jsonToSelection = (json: Record<string, unknown>) => {
const ctor = this._selectionConstructors[json.type as string];
if (!ctor) {
throw new BlockSuiteError(
ErrorCode.SelectionError,
`Unknown selection type: ${json.type}`
);
}
return ctor.fromJSON(json);
};
slots = {
changed: new Subject<BaseSelection[]>(),
remoteChanged: new Subject<Map<number, BaseSelection[]>>(),
};
override loaded() {
this.store.provider.getAll(SelectionIdentifier).forEach(ctor => {
[ctor].flat().forEach(ctor => {
this._selectionConstructors[ctor.type] = ctor;
});
});
this.store.awarenessStore.awareness.on(
'change',
(change: { updated: number[]; added: number[]; removed: number[] }) => {
const all = change.updated.concat(change.added).concat(change.removed);
const localClientID = this.store.awarenessStore.awareness.clientID;
const exceptLocal = all.filter(id => id !== localClientID);
// Only consider remote selections from other clients
if (exceptLocal.length > 0) {
const map = new Map<number, BaseSelection[]>();
this.store.awarenessStore.getStates().forEach((state, id) => {
if (id === this.store.awarenessStore.awareness.clientID) return;
// selection id starts with the same block collection id from others clients would be considered as remote selections
const selection = Object.entries(state.selectionV2)
.filter(([key]) => key.startsWith(this.store.id))
.flatMap(([_, selection]) => selection);
const selections = selection
.map(json => {
try {
return this._jsonToSelection(json);
} catch (error) {
console.error(
'Parse remote selection failed:',
id,
json,
error
);
return null;
}
})
.filter((sel): sel is BaseSelection => !!sel);
map.set(id, selections);
});
this._remoteSelections.value = map;
this.slots.remoteChanged.next(map);
}
}
);
this.store.history.undoManager.on('stack-item-added', this._itemAdded);
this.store.history.undoManager.on('stack-item-popped', this._itemPopped);
}
override disposed() {
super.disposed();
this.store.history.undoManager.off('stack-item-added', this._itemAdded);
this.store.history.undoManager.off('stack-item-popped', this._itemPopped);
}
get value() {
return this._selections.value;
}
get remoteSelections() {
return this._remoteSelections.value;
}
clear(types?: string[]) {
if (types) {
const values = this.value.filter(
selection => !types.includes(selection.type)
);
this.set(values);
} else {
this.set([]);
}
}
create<T extends SelectionConstructor>(
Type: T,
...args: ConstructorParameters<T>
): InstanceType<T> {
return new Type(...args) as InstanceType<T>;
}
getGroup(group: string) {
return this.value.filter(s => s.group === group);
}
filter<T extends SelectionConstructor>(type: T) {
return this.filter$(type).value;
}
filter$<T extends SelectionConstructor>(type: T) {
return computed(() =>
this.value.filter((sel): sel is InstanceType<T> => sel.is(type))
);
}
find<T extends SelectionConstructor>(type: T) {
return this.find$(type).value;
}
find$<T extends SelectionConstructor>(type: T) {
return computed(() =>
this.value.find((sel): sel is InstanceType<T> => sel.is(type))
);
}
set(selections: BaseSelection[]) {
this.store.awarenessStore.setLocalSelection(
this._id,
selections.map(s => s.toJSON())
);
this._selections.value = selections;
this.slots.changed.next(selections);
}
setGroup(group: string, selections: BaseSelection[]) {
const current = this.value.filter(s => s.group !== group);
this.set([...current, ...selections]);
}
// This method is used to clear **current editor's remote selections**
// When the editor is not active, the remote selections should be cleared
// So other editors won't see the remote selections from this editor
clearRemote() {
this.store.awarenessStore.setLocalSelection(this._id, []);
}
update(fn: (currentSelections: BaseSelection[]) => BaseSelection[]) {
const selections = fn(this.value);
this.set(selections);
}
fromJSON(json: Record<string, unknown>[]) {
const selections = json.map(json => {
return this._jsonToSelection(json);
});
return this.set(selections);
}
}

View File

@@ -0,0 +1,9 @@
import type { BaseSelection } from './base';
export interface SelectionConstructor<T extends BaseSelection = BaseSelection> {
type: string;
group: string;
new (...args: any[]): T;
fromJSON(json: Record<string, unknown>): T;
}

View File

@@ -0,0 +1,61 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { Store } from '../model/store';
import { StoreIdentifier } from '../model/store/identifier';
import { Extension } from './extension';
export const StoreExtensionIdentifier =
createIdentifier<StoreExtension>('StoreExtension');
export const storeExtensionSymbol = Symbol('StoreExtension');
/**
* Store extensions are used to extend the store.
* They should be registered to the store. And they should be able to run in a none-dom environment.
*
* @category Extension
*/
export class StoreExtension extends Extension {
/**
* The key of the store extension.
* **You must override this property with a unique string.**
*/
static readonly key: string;
constructor(readonly store: Store) {
super();
}
/**
* Lifecycle hook when the yjs document is loaded.
*/
loaded() {}
/**
* Lifecycle hook when the yjs document is disposed.
*/
disposed() {}
static readonly [storeExtensionSymbol] = true;
static override setup(di: Container) {
if (!this.key) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'Key is not defined in the StoreExtension'
);
}
di.add(this, [StoreIdentifier]);
di.addImpl(StoreExtensionIdentifier(this.key), provider =>
provider.get(this)
);
}
}
export function isStoreExtensionConstructor(
extension: object
): extension is typeof StoreExtension {
return storeExtensionSymbol in extension;
}

View File

@@ -0,0 +1,38 @@
import { createIdentifier } from '@blocksuite/global/di';
import type * as Y from 'yjs';
import type { Store, StoreOptions, YBlock } from '../../model';
import type { AwarenessStore } from '../../yjs';
import type { Workspace } from './workspace.js';
import type { DocMeta } from './workspace-meta.js';
export type GetStoreOptions = Omit<StoreOptions, 'schema' | 'doc'>;
export type RemoveStoreOptions = Pick<
StoreOptions,
'query' | 'id' | 'readonly'
>;
export interface Doc {
readonly id: string;
get meta(): DocMeta | undefined;
remove(): void;
load(initFn?: () => void): void;
get ready(): boolean;
dispose(): void;
clear(): void;
getStore(options?: GetStoreOptions): Store;
removeStore(options: RemoveStoreOptions): void;
get loaded(): boolean;
get awarenessStore(): AwarenessStore;
get workspace(): Workspace;
get rootDoc(): Y.Doc;
get spaceDoc(): Y.Doc;
get yBlocks(): Y.Map<YBlock>;
}
export const DocIdentifier = createIdentifier<Doc>('store-doc');

View File

@@ -0,0 +1,3 @@
export * from './doc';
export * from './workspace';
export * from './workspace-meta';

View File

@@ -0,0 +1,40 @@
import type { Subject } from 'rxjs';
export type Tag = {
id: string;
value: string;
color: string;
};
export type DocsPropertiesMeta = {
tags?: {
options: Tag[];
};
};
export interface DocMeta {
id: string;
title: string;
tags: string[];
createDate: number;
updatedDate?: number;
favorite?: boolean;
trash?: boolean;
}
export interface WorkspaceMeta {
get docMetas(): DocMeta[];
addDocMeta(props: DocMeta, index?: number): void;
getDocMeta(id: string): DocMeta | undefined;
setDocMeta(id: string, props: Partial<DocMeta>): void;
removeDocMeta(id: string): void;
get properties(): DocsPropertiesMeta;
setProperties(meta: DocsPropertiesMeta): void;
get docs(): unknown[] | undefined;
initialize(): void;
docMetaAdded: Subject<string>;
docMetaRemoved: Subject<string>;
docMetaUpdated: Subject<void>;
}

View File

@@ -0,0 +1,30 @@
import type { BlobEngine } from '@blocksuite/sync';
import type { Subject } from 'rxjs';
import type { Awareness } from 'y-protocols/awareness.js';
import type * as Y from 'yjs';
import type { IdGenerator } from '../../utils/id-generator';
import type { Doc } from './doc.js';
import type { WorkspaceMeta } from './workspace-meta.js';
export interface Workspace {
readonly id: string;
readonly meta: WorkspaceMeta;
readonly idGenerator: IdGenerator;
readonly blobSync: BlobEngine;
readonly onLoadDoc?: (doc: Y.Doc) => void;
readonly onLoadAwareness?: (awareness: Awareness) => void;
get doc(): Y.Doc;
get docs(): Map<string, Doc>;
slots: {
docListUpdated: Subject<void>;
};
createDoc(docId?: string): Doc;
getDoc(docId: string): Doc | null;
removeDoc(docId: string): void;
dispose(): void;
}

View File

@@ -0,0 +1,23 @@
export * from './adapter';
export * from './extension';
export * from './model';
export * from './reactive';
export * from './schema';
export * from './transformer';
export { type IdGenerator, nanoid, uuidv4 } from './utils/id-generator';
export * from './yjs';
const env = (typeof globalThis !== 'undefined'
? globalThis
: typeof window !== 'undefined'
? window
: {}) as unknown as Record<string, boolean>;
const importIdentifier = '__ $BLOCKSUITE_STORE$ __';
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!'
);
}
env[importIdentifier] = true;

View File

@@ -0,0 +1,158 @@
import type { Disposable } from '@blocksuite/global/disposable';
import { computed, type Signal, signal } from '@preact/signals-core';
import { Subject } from 'rxjs';
import { take } from 'rxjs/operators';
import type { Text } from '../../reactive/index.js';
import type { Store } from '../store/store.js';
import type { YBlock } from './types.js';
import type { BlockSchemaType } from './zod.js';
type SignaledProps<Props> = Props & {
[P in keyof Props & string as `${P}$`]: Signal<Props[P]>;
};
const modelLabel = Symbol('model_label');
export class BlockModel<Props extends object = object> {
private readonly _children = signal<string[]>([]);
private _store!: Store;
private readonly _childModels = computed(() => {
const value: BlockModel[] = [];
this._children.value.forEach(id => {
const block = this._store.getBlock$(id);
if (block) {
value.push(block.model);
}
});
return value;
});
private readonly _onCreated: Disposable;
private readonly _onDeleted: Disposable;
childMap = computed(() =>
this._children.value.reduce((map, id, index) => {
map.set(id, index);
return map;
}, new Map<string, number>())
);
created = new Subject<void>();
deleted = new Subject<void>();
id!: string;
schema!: BlockSchemaType;
isEmpty() {
return this.children.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 Subject<{ key: string }>();
stash!: (prop: keyof Props & string) => void;
get text(): Text | undefined {
return (this.props as { text?: Text }).text;
}
set text(text: Text) {
if (this.keys.includes('text')) {
(this.props as { text?: Text }).text = text;
}
}
yBlock!: YBlock;
_props!: SignaledProps<Props>;
get props() {
if (!this._props) {
throw new Error('props is only supported in flat data model');
}
return this._props;
}
get flavour(): string {
return this.schema.model.flavour;
}
get version() {
return this.schema.version;
}
get children() {
return this._childModels.value;
}
get store() {
return this._store;
}
set store(doc: Store) {
this._store = doc;
}
get parent() {
return this.store.getParent(this);
}
get role() {
return this.schema.model.role;
}
constructor() {
this._onCreated = {
dispose: this.created.pipe(take(1)).subscribe(() => {
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();
}
});
}).unsubscribe,
};
this._onDeleted = {
dispose: this.deleted.pipe(take(1)).subscribe(() => {
this._onCreated.dispose();
}).unsubscribe,
};
}
dispose() {
this.created.complete();
this.deleted.complete();
this.propsUpdated.complete();
}
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();
}
}

View File

@@ -0,0 +1,65 @@
import type { Schema } from '../../schema/index.js';
import type { Store } from '../store/store.js';
import { FlatSyncController } from './flat-sync-controller.js';
import { SyncController } from './sync-controller.js';
import type { BlockOptions, YBlock } from './types.js';
export type BlockViewType = 'bypass' | 'display' | 'hidden';
export class Block {
private readonly _syncController: SyncController | FlatSyncController;
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?: Store,
readonly options: BlockOptions = {}
) {
const onChange = !options.onChange
? undefined
: (key: string, isLocal: boolean) => {
if (!this._syncController || !this.model) {
return;
}
options.onChange?.(this, key, isLocal);
};
const flavour = yBlock.get('sys:flavour') as string;
const blockSchema = this.schema.get(flavour);
if (blockSchema?.model.isFlatData) {
this._syncController = new FlatSyncController(
schema,
yBlock,
doc,
onChange
);
} else {
this._syncController = new SyncController(schema, yBlock, doc, onChange);
}
}
}

View File

@@ -0,0 +1,42 @@
import type { BlockModel } from './block-model.js';
type PropsInDraft = 'version' | 'flavour' | 'role' | 'id' | 'keys' | 'text';
type ModelProps<Model> = Model extends BlockModel<infer U> ? U : never;
const draftModelSymbol = Symbol('draftModel');
export type DraftModel<Model extends BlockModel = BlockModel> = Pick<
Model,
PropsInDraft
> & {
children: DraftModel[];
props: ModelProps<Model>;
[draftModelSymbol]: true;
};
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) => {
const target = origin.props;
const value = target[key as keyof typeof target];
return {
...acc,
[key]: value,
};
}, {} as ModelProps<Model>);
return {
id,
version,
flavour,
role,
keys,
text,
children: children.map(toDraftModel),
props,
} as DraftModel<Model>;
}

View File

@@ -0,0 +1,164 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import * as Y from 'yjs';
import { ReactiveFlatYMap } from '../../reactive/flat-native-y/index.js';
import type { Schema } from '../../schema/schema.js';
import type { Store } from '../store/store.js';
import { BlockModel } from './block-model.js';
import type { YBlock } from './types.js';
import { internalPrimitives } from './zod.js';
export class FlatSyncController {
private _reactive!: ReactiveFlatYMap;
readonly flavour: string;
readonly id: string;
readonly model: BlockModel;
readonly version: number;
readonly yChildren: Y.Array<string[]>;
constructor(
readonly schema: Schema,
readonly yBlock: YBlock,
readonly doc?: Store,
readonly onChange?: (key: string, isLocal: boolean) => 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);
}
private _createModel(props: Set<string>) {
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 defaultProps = schema.model.props?.(internalPrimitives);
model.schema = schema;
model.id = this.id;
model.keys = Array.from(props);
model.yBlock = this.yBlock;
const reactive = new ReactiveFlatYMap(
this.yBlock,
model.deleted,
this.onChange,
defaultProps
);
this._reactive = reactive;
const proxy = reactive.proxy;
model._props = proxy;
model.stash = this.stash;
model.pop = this.pop;
if (this.doc) {
model.store = this.doc;
}
return model;
}
private _parseYBlock() {
let id: string | undefined;
let flavour: string | undefined;
let version: number | undefined;
let yChildren: Y.Array<string[]> | undefined;
const props: Set<string> = new Set();
this.yBlock.forEach((value, key) => {
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 (key.startsWith('prop:')) {
const keyName = key.replace('prop:', '');
const keys = keyName.split('.');
const propKey = keys[0];
props.add(propKey);
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`
);
}
if (typeof version !== 'number') {
// no version found in data, set to schema version
version = schema.version;
}
const defaultProps = schema.model.props?.(internalPrimitives);
// Set default props if not exists
if (defaultProps) {
Object.keys(defaultProps).forEach(key => {
if (props.has(key)) return;
props.add(key);
});
}
return {
id,
flavour,
version,
props,
yChildren,
};
}
get stash() {
return this._reactive.stash;
}
get pop() {
return this._reactive.pop;
}
}

View File

@@ -0,0 +1,5 @@
export * from './block.js';
export * from './block-model.js';
export * from './draft.js';
export * from './types.js';
export * from './zod.js';

View File

@@ -0,0 +1,391 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { effect, signal } from '@preact/signals-core';
import { createMutex } from 'lib0/mutex.js';
import * as Y from 'yjs';
import {
Boxed,
createYProxy,
native2Y,
type UnRecord,
y2Native,
} from '../../reactive/index.js';
import type { Schema } from '../../schema/schema.js';
import type { Store } from '../store/store.js';
import { BlockModel } from './block-model.js';
import type { YBlock } from './types.js';
import { internalPrimitives } from './zod.js';
/**
* @internal
* SyncController is responsible for syncing the block data with Yjs.
* It creates a proxy model that syncs with Yjs and provides a reactive interface.
* It also handles the stashing and popping of props.
* It will also provide signals for block props.
*
*/
export class SyncController {
private _byPassProxy: boolean = false;
private readonly _byPassUpdate = (fn: () => void) => {
this._byPassProxy = true;
fn();
this._byPassProxy = false;
};
private readonly _mutex = createMutex();
private readonly _observeYBlockChanges = () => {
this.yBlock.observe(event => {
event.keysChanged.forEach(key => {
const type = event.changes.keys.get(key);
if (!type) {
return;
}
const isLocal =
!this.yBlock.doc ||
!event.transaction.origin ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this.yBlock.doc.clientID;
if (type.action === 'update' || type.action === 'add') {
const value = this.yBlock.get(key);
const keyName = key.replace('prop:', '');
const proxy = this._getPropsProxy(keyName, value);
this._byPassUpdate(() => {
// @ts-expect-error allow magic props
this.model.props[keyName] = proxy;
const signalKey = `${keyName}$`;
this._mutex(() => {
if (signalKey in this.model.props) {
// @ts-expect-error allow magic props
this.model.props[signalKey].value = y2Native(value);
}
});
});
this.onChange?.(keyName, isLocal);
return;
}
if (type.action === 'delete') {
const keyName = key.replace('prop:', '');
this._byPassUpdate(() => {
// @ts-expect-error allow magic props
delete this.model.props[keyName];
if (`${keyName}$` in this.model.props) {
// @ts-expect-error allow magic props
this.model.props[`${keyName}$`].value = undefined;
}
});
this.onChange?.(keyName, isLocal);
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?: Store,
readonly onChange?: (key: string, isLocal: boolean) => 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>();
model.schema = schema;
const signalWithProps = Object.entries(props).reduce(
(acc, [key, value]) => {
const data = signal(value);
const dispose = effect(() => {
const value = data.value;
if (!this.model) return;
_mutex(() => {
// @ts-expect-error allow magic props
this.model.props[key] = value;
});
});
const subscription = model.deleted.subscribe(() => {
subscription.unsubscribe();
dispose();
});
return {
...acc,
[`${key}$`]: data,
[key]: value,
};
},
{} as Record<string, unknown>
);
model.id = this.id;
model.keys = Object.keys(props);
model.yBlock = this.yBlock;
model.stash = this.stash;
model.pop = this.pop;
if (this.doc) {
model.store = this.doc;
}
const proxy = new Proxy(signalWithProps, {
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, true);
return result;
}
const yValue = native2Y(value);
if (this.yBlock.get(`prop:${p}`) === yValue) {
return Reflect.set(target, p, value, receiver);
}
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);
},
});
model._props = proxy;
function setValue(target: UnRecord, p: string, value: unknown) {
_mutex(() => {
// @ts-expect-error allow magic props
target[`${p}$`].value = value;
});
}
return model;
}
private _getPropsProxy(name: string, value: unknown) {
return createYProxy(value, {
onChange: (_, isLocal) => {
this.onChange?.(name, isLocal);
const signalKey = `${name}$`;
if (signalKey in this.model.props) {
this._mutex(() => {
// @ts-expect-error allow magic props
this.model.props[signalKey].value = y2Native(value);
});
}
},
});
}
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);
if (value !== undefined) {
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.props[prop];
this._stashed.delete(prop);
model.props[prop] = value;
}
private _stashProp(prop: string) {
(this.model as BlockModel<Record<string, unknown>>).props[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, true);
return result;
},
deleteProperty: (target, p) => {
const result = Reflect.deleteProperty(target, p);
this.onChange?.(prop, true);
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, true);
return result;
},
deleteProperty: (target, p) => {
const result = Reflect.deleteProperty(target, p);
this.onChange?.(p as string, true);
return result;
},
});
}
return value;
},
}
);
}
}

View File

@@ -0,0 +1,23 @@
import type * as Y from 'yjs';
import type { BlockModel } from './block-model.js';
import type { Block } from './index.js';
export type YBlock = Y.Map<unknown> & {
get(prop: 'sys:id' | 'sys:flavour'): string;
get(prop: 'sys:children'): Y.Array<string>;
get<T = unknown>(prop: string): T;
};
export type BlockOptions = {
onChange?: (block: Block, key: string, isLocal: boolean) => void;
};
export type BlockSysProps = {
id: string;
flavour: string;
children?: BlockModel[];
};
export type BlockProps = BlockSysProps & Record<string, unknown>;
export type PropsOfModel<T> = T extends BlockModel<infer P> ? P : never;

View File

@@ -0,0 +1,117 @@
import type * as Y from 'yjs';
import { z } from 'zod';
import { Boxed, Text } from '../../reactive/index.js';
import type { BaseBlockTransformer } from '../../transformer/base.js';
import type { BlockModel } from './block-model.js';
const FlavourSchema = z.string();
const ParentSchema = z.array(z.string()).optional();
const ContentSchema = z.array(z.string()).optional();
const RoleSchema = z.string();
export type RoleType = 'root' | 'content' | string;
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,
isFlatData: z.boolean().optional(),
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(z.custom<Map<string, unknown>>())
.returns(z.custom<BaseBlockTransformer>())
.optional(),
});
export type BlockSchemaType = z.infer<typeof BlockSchema>;
export type PropsGetter<Props> = (
internalPrimitives: InternalPrimitives
) => Props;
export function defineBlockSchema<
Flavour extends string,
Role extends RoleType,
Props extends object,
Metadata extends Readonly<{
version: number;
role: Role;
parent?: string[];
children?: string[];
isFlatData?: boolean;
}>,
Model extends BlockModel<Props>,
Transformer extends BaseBlockTransformer<Props>,
>(options: {
flavour: Flavour;
metadata: Metadata;
props?: (internalPrimitives: InternalPrimitives) => Props;
toModel?: () => Model;
transformer?: (transformerConfig: Map<string, unknown>) => Transformer;
}): {
version: number;
model: {
props: PropsGetter<Props>;
flavour: Flavour;
} & Metadata;
transformer?: (transformerConfig: Map<string, unknown>) => Transformer;
};
export function defineBlockSchema({
flavour,
props,
metadata,
toModel,
transformer,
}: {
flavour: string;
metadata: {
version: number;
role: RoleType;
parent?: string[];
children?: string[];
isFlatData?: boolean;
};
props?: (internalPrimitives: InternalPrimitives) => Record<string, unknown>;
toModel?: () => BlockModel;
transformer?: (
transformerConfig: Map<string, unknown>
) => BaseBlockTransformer;
}): BlockSchemaType {
const schema = {
version: metadata.version,
model: {
role: metadata.role,
parent: metadata.parent,
children: metadata.children,
flavour,
props,
toModel,
isFlatData: metadata.isFlatData,
},
transformer,
} satisfies z.infer<typeof BlockSchema>;
BlockSchema.parse(schema);
return schema;
}

View File

@@ -0,0 +1,3 @@
export * from './block/index.js';
export * from './store/index.js';
export * from './store-container.js';

View File

@@ -0,0 +1,86 @@
import type {
Doc,
ExtensionType,
GetStoreOptions,
RemoveStoreOptions,
} from '../extension';
import { DocIdentifier } from '../extension/workspace';
import { type Query, Store } from './store';
export class StoreContainer {
private readonly _storeMap = new Map<string, Store>();
constructor(readonly doc: Doc) {}
getStore = ({
readonly,
query,
provider,
extensions,
id,
}: GetStoreOptions = {}) => {
let idOrOptions: string | { readonly?: boolean; query?: Query };
if (readonly || query) {
idOrOptions = { readonly, query };
} else if (!id) {
idOrOptions = this.doc.workspace.idGenerator();
} else {
idOrOptions = id;
}
const key = this._getQueryKey(idOrOptions);
if (this._storeMap.has(key)) {
return this._storeMap.get(key) as Store;
}
const storeExtension: ExtensionType = {
setup: di => {
di.addImpl(DocIdentifier, () => this.doc);
},
};
const doc = new Store({
doc: this.doc,
readonly,
query,
provider,
extensions: [storeExtension].concat(extensions ?? []),
});
this._storeMap.set(key, doc);
return doc;
};
removeStore = ({ readonly, query, id }: RemoveStoreOptions) => {
let idOrOptions: string | { readonly?: boolean; query?: Query };
if (readonly || query) {
idOrOptions = { readonly, query };
} else if (!id) {
return;
} else {
idOrOptions = id;
}
const key = this._getQueryKey(idOrOptions);
this._storeMap.delete(key);
};
private readonly _getQueryKey = (
idOrOptions: string | { readonly?: boolean; query?: Query }
) => {
if (typeof idOrOptions === 'string') {
return idOrOptions;
}
const { readonly, query } = idOrOptions;
const readonlyKey = this._getReadonlyKey(readonly);
const key = JSON.stringify({
readonlyKey,
query,
});
return key;
};
private _getReadonlyKey(readonly?: boolean): 'true' | 'false' {
return (readonly?.toString() as 'true' | 'false') ?? 'false';
}
}

View File

@@ -0,0 +1,397 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import * as Y from 'yjs';
import { isPureObject, native2Y } from '../../reactive/index.js';
import type { Schema } from '../../schema/index.js';
import type { BlockModel } from '../block/block-model.js';
import type { YBlock } from '../block/types.js';
import { internalPrimitives } from '../block/zod.js';
export class DocCRUD {
get root(): string | null {
let rootId: string | null = null;
this._yBlocks.forEach(yBlock => {
const flavour = yBlock.get('sys:flavour') as string;
const schema = this._schema.flavourSchemaMap.get(flavour);
if (!schema) return;
if (schema.model.role === 'root') {
rootId = yBlock.get('sys:id') as string;
}
});
return rootId;
}
constructor(
private readonly _yBlocks: Y.Map<YBlock>,
private readonly _schema: Schema
) {}
private _getSiblings<T>(
id: string,
fn: (index: number, parent: YBlock) => T
) {
const parentId = this.getParent(id);
if (!parentId) return null;
const parent = this._yBlocks.get(parentId);
if (!parent) return null;
const children = parent.get('sys:children');
const index = children.toArray().indexOf(id);
if (index === -1) return null;
return fn(index, parent);
}
addBlock(
id: string,
flavour: string,
initialProps: Record<string, unknown> = {},
parent?: string | null,
parentIndex?: number
) {
const schema = this._schema.flavourSchemaMap.get(flavour);
if (!schema) {
throw new BlockSuiteError(
ErrorCode.ModelCRUDError,
`schema for flavour: ${flavour} not found`
);
}
const hasBlock = this._yBlocks.has(id);
if (hasBlock) {
throw new BlockSuiteError(
ErrorCode.ModelCRUDError,
`Should not add existing block: ${id}`
);
}
const parentFlavour = parent
? this._yBlocks.get(parent)?.get('sys:flavour')
: undefined;
this._schema.validate(flavour, parentFlavour as string);
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;
const isFlatData = schema.model.isFlatData;
if (isFlatData) {
const run = (obj: unknown, basePath: string) => {
if (isPureObject(obj)) {
Object.entries(obj).forEach(([key, value]) => {
const fullPath = basePath ? `${basePath}.${key}` : key;
run(value, fullPath);
});
} else {
yBlock.set(`prop:${basePath}`, native2Y(obj));
}
};
run(props, '');
} else {
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
) {
if (
blocksToMove.length > 1 &&
targetSibling &&
blocksToMove.includes(targetSibling)
) {
console.error(
'Cannot move blocks when the target sibling is in the blocks to move'
);
return;
}
if (blocksToMove.length === 1 && targetSibling === blocksToMove[0]) {
return;
}
if (blocksToMove.includes(newParent)) {
console.error(
'Cannot move blocks when the new parent is in the blocks to move'
);
return;
}
// 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;
}
let targetIndex = targetParentChildren
.toArray()
.findIndex(id => id === targetSibling);
if (targetIndex === -1) {
console.error('Target sibling not found, just insert to the end');
targetIndex = targetParentChildren.length;
}
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);
}
}

View File

@@ -0,0 +1,5 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { Store } from './store';
export const StoreIdentifier = createIdentifier<Store>('Store');

View File

@@ -0,0 +1,3 @@
export * from './identifier.js';
export * from './query.js';
export * from './store.js';

View File

@@ -0,0 +1,82 @@
import isMatch from 'lodash.ismatch';
import type { Block, BlockModel, BlockViewType } from '../block/index.js';
export type QueryMatch = {
id?: string;
flavour?: string;
props?: Record<string, unknown>;
viewType: BlockViewType;
};
/**
* - `strict` means that only blocks that match the query will be included.
* - `loose` means that all blocks will be included first, and then the blocks will be run through the query.
* - `include` means that only blocks and their ancestors that match the query will be included.
*/
type QueryMode = 'strict' | 'loose' | 'include';
export type Query = {
match: QueryMatch[];
mode: QueryMode;
};
export function runQuery(query: Query, block: Block) {
const blockViewType = getBlockViewType(query, block);
block.blockViewType = blockViewType;
if (blockViewType !== 'hidden') {
const queryMode = query.mode;
setAncestorsToDisplayIfHidden(queryMode, block);
}
}
function getBlockViewType(query: Query, block: Block): BlockViewType {
const flavour = block.model.flavour;
const id = block.model.id;
const queryMode = query.mode;
const props = block.model.keys.reduce(
(acc, key) => {
return {
...acc,
[key]: block.model.props[key as keyof BlockModel['props']],
};
},
{} as Record<string, unknown>
);
let blockViewType: BlockViewType =
queryMode === 'loose' ? 'display' : 'hidden';
query.match.some(queryObject => {
const {
id: queryId,
flavour: queryFlavour,
props: queryProps,
viewType,
} = queryObject;
const matchQueryId = queryId == null ? true : queryId === id;
const matchQueryFlavour =
queryFlavour == null ? true : queryFlavour === flavour;
const matchQueryProps =
queryProps == null ? true : isMatch(props, queryProps);
if (matchQueryId && matchQueryFlavour && matchQueryProps) {
blockViewType = viewType;
return true;
}
return false;
});
return blockViewType;
}
function setAncestorsToDisplayIfHidden(mode: QueryMode, block: Block) {
const doc = block.model.store;
let parent = doc.getParent(block.model);
while (parent) {
const parentBlock = doc.getBlock(parent.id);
if (parentBlock && parentBlock.blockViewType === 'hidden') {
parentBlock.blockViewType = mode === 'include' ? 'display' : 'bypass';
}
parent = doc.getParent(parent);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
import type { z } from 'zod';
import { SYS_KEYS } from '../../consts.js';
import type { BlockModel } from '../block/block-model.js';
import type { BlockProps, YBlock } from '../block/types.js';
import type { BlockSchema } from '../block/zod.js';
import { internalPrimitives } from '../block/zod.js';
export function syncBlockProps(
schema: z.infer<typeof BlockSchema>,
model: BlockModel,
yBlock: YBlock,
props: Partial<BlockProps>
) {
const defaultProps = schema.model.props?.(internalPrimitives) ?? {};
Object.entries(props).forEach(([key, value]) => {
if (SYS_KEYS.has(key)) return;
if (value === undefined) return;
// @ts-expect-error allow props
model.props[key] = value;
});
// set default value
Object.entries(defaultProps).forEach(([key, value]) => {
const notExists =
!yBlock.has(`prop:${key}`) || yBlock.get(`prop:${key}`) === undefined;
if (!notExists) {
return;
}
// @ts-expect-error allow props
model[key] = value;
});
}

View File

@@ -0,0 +1,76 @@
import * as Y from 'yjs';
import type { ProxyOptions } from './types';
export abstract class BaseReactiveYData<
T,
YSource extends Y.AbstractType<any>,
> {
protected _getOrigin = (
doc: Y.Doc
): {
doc: Y.Doc;
proxy: true;
target: BaseReactiveYData<any, any>;
} => {
return {
doc,
proxy: true,
target: this,
};
};
protected _onObserve = (event: Y.YEvent<any>, handler: () => void) => {
if (
event.transaction.origin?.force === true ||
(event.transaction.origin?.proxy !== true &&
(!event.transaction.local ||
event.transaction.origin instanceof Y.UndoManager))
) {
handler();
}
const isLocal =
!event.transaction.origin ||
!this._ySource.doc ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this._ySource.doc.clientID;
this._options?.onChange?.(this._proxy, isLocal);
};
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: Y.Doc, fn: () => void) => {
doc.transact(fn, this._getOrigin(doc));
};
protected _updateWithSkip = (fn: () => void) => {
if (this._skipNext) {
return;
}
this._skipNext = true;
fn();
this._skipNext = false;
};
protected abstract readonly _ySource: YSource;
get proxy() {
return this._proxy;
}
abstract pop(prop: string | number): void;
abstract stash(prop: string | number): void;
}

View File

@@ -0,0 +1,137 @@
import * as Y from 'yjs';
import { NATIVE_UNIQ_IDENTIFIER } from '../consts.js';
export type OnBoxedChange = (data: unknown, isLocal: boolean) => void;
/**
* Boxed is to store raw data in Yjs.
* By default, store will try to convert a object to a Y.Map.
* If you want to store a raw object for you want to manipulate the Y.Map directly, you can use Boxed.
*
* > [!NOTE]
* > Please notice that the data will be stored in Y.Map anyway so it can not hold data structure like function.
*
* @example
* ```ts
* const boxedObject = new Boxed({ a: 1, b: 2 });
* const boxedYMap = new Boxed(new Y.Map());
* boxedObject.getValue().a; // 1
* boxedYMap.getValue().set('a', 1);
* boxedObject.setValue({ foo: 'bar' });
* ```
*
* @typeParam T - The type of the value stored in the Boxed.
*
* @category Reactive
*/
export class Boxed<Value = unknown> {
/**
* Create a Boxed from a Y.Map.
* It is useful when you sync a Y.Map from remote.
*
* @typeParam Value - The type of the value.
*
* @example
* ```ts
* const doc1 = new Y.Doc();
* const doc2 = new Y.Doc();
* keepSynced(doc1, doc2);
*
* const data1 = doc1.getMap('data');
* const boxed1 = new Boxed({ a: 1, b: 2 });
* data1.set('boxed', boxed1.yMap);
*
* const data2 = doc2.getMap('data');
* const boxed2 = Boxed.from<{ a: number; b: number }>(data2.get('boxed'));
* ```
*/
static from = <Value>(
map: Y.Map<unknown>,
/** @internal */
onChange?: OnBoxedChange
): Boxed<Value> => {
const boxed = new Boxed<Value>(map.get('value') as Value);
if (onChange) {
boxed.bind(onChange);
}
return boxed;
};
/**
* Check if a value is a Boxed.
*
* @example
* ```ts
* const doc = new Y.Doc();
*
* const data = doc.getMap('data');
* const boxed = new Boxed({ a: 1, b: 2 });
* Boxed.is(boxed); // true
*
* data.set('boxed', boxed.yMap);
* Boxed.is(data.get('boxed)); // true
* ```
*/
static is = (value: unknown): value is Boxed => {
return (
value instanceof Y.Map && value.get('type') === NATIVE_UNIQ_IDENTIFIER
);
};
private readonly _map: Y.Map<Value>;
private _onChange?: OnBoxedChange;
/**
* Get the current value of the Boxed.
*/
getValue = () => {
return this._map.get('value');
};
/**
* Replace the current value of the Boxed.
*
* @param value - The new value to set.
*/
setValue = (value: Value) => {
return this._map.set('value', value);
};
/** @internal */
get yMap() {
return this._map;
}
constructor(value: Value) {
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 Value);
this._map.set('value', value);
}
this._map.observeDeep(events => {
events.forEach(event => {
const isLocal =
!event.transaction.origin ||
!this._map.doc ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this._map.doc.clientID;
this._onChange?.(this.getValue(), isLocal);
});
});
}
/** @internal */
bind(onChange: OnBoxedChange) {
this._onChange = onChange;
}
}

View File

@@ -0,0 +1,167 @@
import { signal } from '@preact/signals-core';
import type { Subject } from 'rxjs';
import { Array as YArray, type Map as YMap, type YMapEvent } from 'yjs';
import { BaseReactiveYData } from '../base-reactive-data';
import { Boxed, type OnBoxedChange } from '../boxed';
import { ReactiveYArray } from '../proxy';
import { type OnTextChange, Text } from '../text';
import type { ProxyOptions, UnRecord } from '../types';
import { initializeData } from './initialize';
import { createProxy } from './proxy';
import type { OnChange } from './types';
import { getYEventHandler } from './y-event-handler';
export class ReactiveFlatYMap extends BaseReactiveYData<
UnRecord,
YMap<unknown>
> {
protected readonly _proxy: UnRecord;
protected readonly _source: UnRecord;
protected readonly _options?: ProxyOptions<UnRecord>;
private readonly _initialized;
private readonly _observer = (event: YMapEvent<unknown>) => {
const yMap = this._ySource;
const proxy = this._proxy;
this._onObserve(event, () => {
getYEventHandler({
yMap,
proxy,
stashed: this._stashed,
updateWithYjsSkip: this._updateWithYjsSkip,
transform: this._transform,
onChange: this._onChange,
event,
});
});
};
private readonly _transform = (
key: string,
value: unknown,
origin: unknown
) => {
const onChange = this._getPropOnChange(key);
if (value instanceof Text) {
value.bind(onChange as OnTextChange);
return value;
}
if (Boxed.is(origin)) {
(value as Boxed).bind(onChange as OnBoxedChange);
return value;
}
if (origin instanceof YArray) {
const data = new ReactiveYArray(value as unknown[], origin, {
onChange,
});
return data.proxy;
}
return value;
};
private readonly _getPropOnChange = (key: string) => {
return (_: unknown, isLocal: boolean) => {
this._onChange?.(key, isLocal);
};
};
private _byPassYjs = false;
private readonly _getProxy = (
source: UnRecord,
root: UnRecord,
path?: string
): UnRecord =>
createProxy({
yMap: this._ySource,
base: source,
root,
onDispose: this._onDispose,
shouldByPassSignal: () => this._skipNext,
byPassSignalUpdate: this._updateWithSkip,
shouldByPassYjs: () => this._byPassYjs,
basePath: path,
onChange: this._onChange,
transform: this._transform,
stashed: this._stashed,
initialized: () => this._initialized,
});
private readonly _updateWithYjsSkip = (fn: () => void) => {
this._byPassYjs = true;
fn();
this._byPassYjs = false;
};
constructor(
protected readonly _ySource: YMap<unknown>,
private readonly _onDispose: Subject<void>,
private readonly _onChange?: OnChange,
defaultProps?: Record<string, unknown>
) {
super();
this._initialized = false;
const source = initializeData({
getProxy: this._getProxy,
transform: this._transform,
yMap: this._ySource,
});
this._source = source;
const proxy = this._getProxy(source, source);
const initSignals = (key: string, value: unknown) => {
const signalData = signal(value);
source[`${key}$`] = signalData;
const unsubscribe = signalData.subscribe(next => {
if (!this._initialized) {
return;
}
this._updateWithSkip(() => {
proxy[key] = next;
this._onChange?.(key, true);
});
});
const subscription = _onDispose.subscribe(() => {
subscription.unsubscribe();
unsubscribe();
});
};
Object.entries(source).forEach(([key, value]) => {
initSignals(key, value);
});
if (defaultProps) {
Object.entries(defaultProps).forEach(([key, value]) => {
if (!(key in proxy) && value === undefined) {
initSignals(key, value);
}
});
}
this._proxy = proxy;
this._ySource.observe(this._observer);
this._initialized = true;
if (defaultProps) {
Object.entries(defaultProps).forEach(([key, value]) => {
if (key in proxy || value === undefined) return;
proxy[key] = value;
});
}
}
pop = (prop: string): void => {
const value = this._source[prop];
this._stashed.delete(prop);
this._proxy[prop] = value;
};
stash = (prop: string): void => {
this._stashed.add(prop);
};
}

View File

@@ -0,0 +1,59 @@
import { BlockSuiteError } from '@blocksuite/global/exceptions';
import { Array as YArray, Map as YMap, Text as YText } from 'yjs';
import { Boxed } from '../boxed';
import { Text } from '../text';
import type { UnRecord } from '../types';
import type { CreateProxyOptions } from './types';
import { keyWithoutPrefix } from './utils';
type InitializeDataOptions = Pick<CreateProxyOptions, 'transform' | 'yMap'> & {
getProxy: (source: UnRecord, root: UnRecord, path?: string) => UnRecord;
};
// Create default data object from yjs map
export const initializeData = ({
getProxy,
transform,
yMap,
}: InitializeDataOptions): UnRecord => {
const root: UnRecord = {};
Array.from(yMap.entries()).forEach(([key, value]) => {
if (key.startsWith('sys')) {
return;
}
const keys = keyWithoutPrefix(key).split('.');
const firstKey = keys[0];
let finalData = value;
if (Boxed.is(value)) {
finalData = transform(firstKey, new Boxed(value), value);
} else if (value instanceof YArray) {
finalData = transform(firstKey, value.toArray(), value);
} else if (value instanceof YText) {
const next = new Text(value);
finalData = transform(firstKey, next, value);
} else if (value instanceof YMap) {
throw new BlockSuiteError(
BlockSuiteError.ErrorCode.ReactiveProxyError,
'flatY2Native does not support Y.Map as value of Y.Map'
);
} else {
finalData = transform(firstKey, value, value);
}
const allLength = keys.length;
void keys.reduce((acc: UnRecord, key, index) => {
if (!acc[key] && index !== allLength - 1) {
const path = keys.slice(0, index + 1).join('.');
const data = getProxy({} as UnRecord, root, path);
acc[key] = data;
}
if (index === allLength - 1) {
acc[key] = finalData;
}
return acc[key] as UnRecord;
}, root);
});
return root;
};

View File

@@ -0,0 +1,203 @@
import { signal } from '@preact/signals-core';
import { isPureObject } from '../is-pure-object';
import { native2Y } from '../native-y';
import type { UnRecord } from '../types';
import { signalUpdater } from './signal-updater';
import type { CreateProxyOptions } from './types';
import {
bindOnChangeIfNeed,
getFirstKey,
isProxy,
keyWithPrefix,
markProxy,
} from './utils';
import { yMapUpdater } from './y-map-updater';
export function createProxy(options: CreateProxyOptions): UnRecord {
const { base } = options;
if (isProxy(base)) {
return base;
}
initializeProxy(options);
const proxyHandler = createProxyHandler(options);
const proxy = new Proxy(base, proxyHandler);
markProxy(proxy);
return proxy;
}
function initializeProxy(options: CreateProxyOptions) {
const { basePath, yMap, base, root } = options;
Object.entries(base).forEach(([key, value]) => {
if (isPureObject(value) && !isProxy(value)) {
const proxy = createProxy({
...options,
yMap,
base: value as UnRecord,
root,
basePath: basePath ? `${basePath}.${key}` : key,
});
base[key] = proxy;
}
});
}
function updateSignal(
value: unknown,
prop: string,
receiver: any,
options: CreateProxyOptions
) {
const {
root,
shouldByPassSignal,
byPassSignalUpdate,
onChange,
basePath,
initialized,
onDispose,
shouldByPassYjs,
} = options;
const fullPath = basePath ? `${basePath}.${prop}` : prop;
const firstKey = getFirstKey(fullPath);
signalUpdater({
root,
firstKey,
shouldByPassSignal,
shouldByPassYjs,
byPassSignalUpdate,
onChange,
basePath,
value,
handleNestedUpdate: (signalKey: string) => {
if (value === undefined) {
delete root[signalKey];
return;
}
const signalData = signal(value);
root[signalKey] = signalData;
const unsubscribe = signalData.subscribe(next => {
if (!initialized()) {
return;
}
byPassSignalUpdate(() => {
receiver[prop] = next;
onChange?.(firstKey, true);
});
});
const subscription = onDispose.subscribe(() => {
subscription.unsubscribe();
unsubscribe();
});
},
});
}
function createProxyHandler(
options: CreateProxyOptions
): ProxyHandler<UnRecord> {
const {
yMap,
shouldByPassYjs,
basePath,
onChange,
initialized,
transform,
stashed,
} = options;
return {
has: (target, p) => {
return Reflect.has(target, p);
},
get: (target, p, receiver) => {
return Reflect.get(target, p, receiver);
},
set: (target, p, value, receiver) => {
if (typeof p === 'string') {
const fullPath = basePath ? `${basePath}.${p}` : p;
const firstKey = getFirstKey(fullPath);
const isStashed = stashed.has(firstKey);
if (isPureObject(value)) {
const syncYMap = () =>
yMapUpdater({
shouldByPassYjs,
yMap,
initialized,
onChange,
fullPath,
value,
});
if (!isStashed) {
syncYMap();
}
const next = createProxy({
...options,
basePath: fullPath,
base: value as UnRecord,
});
const result = Reflect.set(target, p, next, receiver);
updateSignal(next, p, receiver, options);
return result;
}
bindOnChangeIfNeed(value, () => {
onChange?.(firstKey, true);
});
const yValue = native2Y(value);
const next = transform(firstKey, value, yValue);
if (!isStashed && initialized() && !shouldByPassYjs()) {
yMap.doc?.transact(
() => {
yMap.set(keyWithPrefix(fullPath), yValue);
},
{ proxy: true }
);
}
const result = Reflect.set(target, p, next, receiver);
updateSignal(next, p, receiver, options);
return result;
}
return Reflect.set(target, p, value, receiver);
},
deleteProperty: (target, p) => {
if (typeof p === 'string') {
const fullPath = basePath ? `${basePath}.${p}` : p;
const firstKey = getFirstKey(fullPath);
const isStashed = stashed.has(firstKey);
if (!isStashed && initialized() && !shouldByPassYjs()) {
yMap.doc?.transact(
() => {
const fullKey = keyWithPrefix(fullPath);
yMap.forEach((_, key) => {
if (key.startsWith(fullKey)) {
yMap.delete(key);
}
});
},
{ proxy: true }
);
}
const result = Reflect.deleteProperty(target, p);
updateSignal(undefined, p, undefined, options);
return result;
}
return Reflect.deleteProperty(target, p);
},
};
}

View File

@@ -0,0 +1,58 @@
import { isPureObject } from '../is-pure-object';
import type { CreateProxyOptions } from './types';
type UpdateSignalOptions = Pick<
CreateProxyOptions,
| 'shouldByPassSignal'
| 'root'
| 'onChange'
| 'byPassSignalUpdate'
| 'basePath'
| 'shouldByPassYjs'
> & {
firstKey: string;
value: unknown;
handleNestedUpdate: (signalKey: string) => void;
};
export function signalUpdater({
root,
firstKey,
shouldByPassSignal,
byPassSignalUpdate,
onChange,
basePath,
value,
handleNestedUpdate,
shouldByPassYjs,
}: UpdateSignalOptions): void {
const isRoot = !basePath;
if (shouldByPassSignal()) {
return;
}
const signalKey = `${firstKey}$`;
if (!(signalKey in root)) {
if (!isRoot) {
return;
}
handleNestedUpdate(signalKey);
return;
}
byPassSignalUpdate(() => {
const prev = root[firstKey];
const next = isRoot
? value
: isPureObject(prev)
? { ...prev }
: Array.isArray(prev)
? [...prev]
: prev;
// @ts-expect-error allow magic props
root[signalKey].value = next;
// If the update is from yjs, it's already called from y-event-handler
if (!shouldByPassYjs()) {
onChange?.(firstKey, true);
}
});
}

View File

@@ -0,0 +1,26 @@
import type { Subject } from 'rxjs';
import type { Map as YMap } from 'yjs';
import type { UnRecord } from '../types';
export type OnChange = (key: string, isLocal: boolean) => void;
export type Transform = (
key: string,
value: unknown,
origin: unknown
) => unknown;
export type CreateProxyOptions = {
yMap: YMap<unknown>;
base: UnRecord;
root: UnRecord;
basePath?: string;
onChange?: OnChange;
transform: Transform;
onDispose: Subject<void>;
shouldByPassSignal: () => boolean;
shouldByPassYjs: () => boolean;
byPassSignalUpdate: (fn: () => void) => void;
stashed: Set<string | number>;
initialized: () => boolean;
};

View File

@@ -0,0 +1,50 @@
import { SYS_KEYS } from '../../consts';
import { Boxed } from '../boxed';
import { Text } from '../text';
import type { UnRecord } from '../types';
export const keyWithoutPrefix = (key: string) => key.replace(/(prop|sys):/, '');
export const keyWithPrefix = (key: string) =>
SYS_KEYS.has(key) ? `sys:${key}` : `prop:${key}`;
const proxySymbol = Symbol('proxy');
export function isProxy(value: unknown): boolean {
return proxySymbol in Object.getPrototypeOf(value);
}
export function markProxy(value: UnRecord): UnRecord {
Object.setPrototypeOf(value, {
[proxySymbol]: true,
});
return value;
}
export function isEmptyObject(obj: UnRecord): boolean {
return Object.keys(obj).length === 0;
}
export function deleteEmptyObject(
obj: UnRecord,
key: string,
parent: UnRecord
): void {
if (isEmptyObject(obj)) {
delete parent[key];
}
}
export function getFirstKey(key: string): string {
const result = key.split('.').at(0);
if (!result) {
throw new Error(`Invalid key for: ${key}`);
}
return result;
}
export function bindOnChangeIfNeed(value: unknown, onChange: () => void): void {
if (value instanceof Text || Boxed.is(value)) {
value.bind(onChange);
}
}

View File

@@ -0,0 +1,117 @@
import type { Map as YMap, YMapEvent } from 'yjs';
import { y2Native } from '../native-y';
import type { UnRecord } from '../types';
import {
deleteEmptyObject,
getFirstKey,
isEmptyObject,
keyWithoutPrefix,
} from './utils';
type YEventHandlerOptions = {
event: YMapEvent<unknown>;
yMap: YMap<unknown>;
proxy: UnRecord;
stashed: Set<string | number>;
updateWithYjsSkip: (fn: () => void) => void;
transform: (key: string, value: unknown, origin: unknown) => unknown;
onChange?: (key: string, isAdd: boolean) => void;
};
// update proxy when yjs map changes
export const getYEventHandler = (options: YEventHandlerOptions) => {
const { event } = options;
const { keysChanged, changes } = event;
keysChanged.forEach(key => {
const type = changes.keys.get(key);
if (!type) return;
if (type.action === 'update' || type.action === 'add') {
return handleUpdateOrAdd(key, options);
}
if (type.action === 'delete') {
return handleDelete(key, options);
}
});
};
function isStashed(key: string, stashed: Set<string | number>) {
const keyName: string = keyWithoutPrefix(key);
const firstKey = getFirstKey(keyName);
return stashed.has(firstKey);
}
function handleUpdateOrAdd(
key: string,
{
yMap,
proxy,
stashed,
updateWithYjsSkip,
transform,
onChange,
}: YEventHandlerOptions
) {
if (isStashed(key, stashed)) {
return;
}
const keyName: string = keyWithoutPrefix(key);
const firstKey = getFirstKey(keyName);
updateWithYjsSkip(() => {
const value = yMap.get(key);
const keys = keyName.split('.');
void keys.reduce((acc, key, index, arr) => {
if (!acc[key] && index !== arr.length - 1) {
acc[key] = {};
}
if (index === arr.length - 1) {
acc[key] = y2Native(value, {
transform: (value, origin) => transform(firstKey, value, origin),
});
}
return acc[key] as UnRecord;
}, proxy as UnRecord);
});
onChange?.(firstKey, false);
}
function handleDelete(
key: string,
{ proxy, stashed, updateWithYjsSkip, onChange }: YEventHandlerOptions
) {
if (isStashed(key, stashed)) {
return;
}
const keyName: string = keyWithoutPrefix(key);
const firstKey = getFirstKey(keyName);
updateWithYjsSkip(() => {
const keys = keyName.split('.');
void keys.reduce((acc, key, index) => {
if (index === keys.length - 1) {
delete acc[key];
let curr = acc;
let parentKey = keys[index - 1];
let parent = proxy as UnRecord;
let path = keys.slice(0, -2);
for (let i = keys.length - 2; i > 0; i--) {
for (const pathKey of path) {
parent = parent[pathKey] as UnRecord;
}
if (!isEmptyObject(curr)) {
break;
}
deleteEmptyObject(curr, parentKey, parent);
curr = parent;
parentKey = keys[i - 1];
path = path.slice(0, -1);
parent = proxy as UnRecord;
}
}
return acc[key] as UnRecord;
}, proxy as UnRecord);
});
onChange?.(firstKey, false);
}

View File

@@ -0,0 +1,61 @@
import { isPureObject } from '../is-pure-object';
import { native2Y } from '../native-y';
import type { CreateProxyOptions } from './types';
import {
bindOnChangeIfNeed,
getFirstKey,
keyWithoutPrefix,
keyWithPrefix,
} from './utils';
type YMapOptions = Pick<
CreateProxyOptions,
'shouldByPassYjs' | 'yMap' | 'initialized' | 'onChange'
> & {
fullPath: string;
value: unknown;
};
export function yMapUpdater({
shouldByPassYjs,
yMap,
initialized,
onChange,
fullPath,
value,
}: YMapOptions) {
const firstKey = getFirstKey(fullPath);
if (shouldByPassYjs()) {
return;
}
const list: Array<() => void> = [];
yMap.forEach((_, key) => {
if (initialized() && keyWithoutPrefix(key).startsWith(fullPath)) {
yMap.delete(key);
}
});
const run = (obj: object, basePath: string) => {
Object.entries(obj).forEach(([key, value]) => {
const fullPath = basePath ? `${basePath}.${key}` : key;
if (isPureObject(value)) {
run(value, fullPath);
} else {
list.push(() => {
bindOnChangeIfNeed(value, () => {
onChange?.(firstKey, true);
});
yMap.set(keyWithPrefix(fullPath), native2Y(value));
});
}
});
};
run(value as object, fullPath);
if (list.length && initialized()) {
yMap.doc?.transact(
() => {
list.forEach(fn => fn());
},
{ proxy: true }
);
}
}

View File

@@ -0,0 +1,8 @@
export * from './boxed.js';
export * from './flat-native-y/index.js';
export * from './is-pure-object.js';
export * from './native-y.js';
export * from './proxy.js';
export * from './stash-pop.js';
export * from './text/index.js';
export * from './types.js';

View File

@@ -0,0 +1,8 @@
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)
);
}

View File

@@ -0,0 +1,4 @@
import type { BaseReactiveYData } from './base-reactive-data';
export const proxies = new WeakMap<any, BaseReactiveYData<any, any>>();
export const flatProxies = new WeakMap<any, BaseReactiveYData<any, any>>();

View File

@@ -0,0 +1,74 @@
import { Array as YArray, Map as YMap, Text as YText } from 'yjs';
import { Boxed } from './boxed.js';
import { isPureObject } from './is-pure-object.js';
import { Text } from './text/index.js';
import type { Native2Y, TransformOptions } from './types.js';
export function native2Y<T>(
value: T,
{ deep = true, transform = x => x }: TransformOptions = {}
): Native2Y<T> {
if (value instanceof Boxed) {
return transform(value.yMap, value) as Native2Y<T>;
}
if (value instanceof Text) {
if (value.yText.doc) {
return transform(value.yText.clone(), value) as Native2Y<T>;
}
return transform(value.yText, value) 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 transform(yArray, value) 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 transform(yMap, value) as Native2Y<T>;
}
return transform(value, 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);
}

View File

@@ -0,0 +1,390 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { YArrayEvent, YMapEvent } from 'yjs';
import { Array as YArray, Map as YMap } from 'yjs';
import { BaseReactiveYData } from './base-reactive-data.js';
import { Boxed, type OnBoxedChange } from './boxed.js';
import { proxies } from './memory.js';
import { native2Y, y2Native } from './native-y.js';
import { type OnTextChange, Text } from './text/index.js';
import type { ProxyOptions, TransformOptions, UnRecord } from './types.js';
export class ReactiveYArray extends BaseReactiveYData<
unknown[],
YArray<unknown>
> {
private readonly _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, true);
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) => {
if (p === 'splice') {
return (start: number, deleteCount?: number, ...items: unknown[]) => {
const doc = this._ySource.doc;
if (!doc) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not bound to a Y.Doc'
);
}
const count = deleteCount ?? target.length - start;
const yItems = items.map(item => native2Y(item));
this._transact(doc, () => {
this._ySource.delete(start, count);
this._ySource.insert(start, yItems);
});
const result = Array.prototype.splice.apply(target, [
start,
count,
...yItems.map(yItem => createYProxy(yItem, this._options)),
]);
return result;
};
}
if (p === 'shift') {
return () => {
const doc = this._ySource.doc;
if (!doc) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not bound to a Y.Doc'
);
}
if (target.length === 0) {
return undefined;
}
const result = Array.prototype.shift.call(target);
this._transact(doc, () => {
this._ySource.delete(0, 1);
});
return result;
};
}
if (p === 'unshift') {
return (...items: unknown[]) => {
const doc = this._ySource.doc;
if (!doc) {
throw new BlockSuiteError(
ErrorCode.ReactiveProxyError,
'YData is not bound to a Y.Doc'
);
}
const yItems = items.map(item => native2Y(item));
this._transact(doc, () => {
this._ySource.insert(0, yItems);
});
return Array.prototype.unshift.apply(
target,
yItems.map(yItem => createYProxy(yItem, this._options))
);
};
}
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 readonly _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, true);
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;
// oxlint-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);
}
// oxlint-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;
}
const transform: TransformOptions['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;
};
return y2Native(yAbstract, { transform }) as Data;
}

View File

@@ -0,0 +1,17 @@
import type { Array as YArray, Map as YMap } from 'yjs';
import { proxies } from './memory';
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);
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);
proxy?.pop(prop);
}

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const baseTextAttributes = z.object({
bold: z.literal(true).optional().nullable().catch(undefined),
italic: z.literal(true).optional().nullable().catch(undefined),
underline: z.literal(true).optional().nullable().catch(undefined),
strike: z.literal(true).optional().nullable().catch(undefined),
code: z.literal(true).optional().nullable().catch(undefined),
link: z.string().optional().nullable().catch(undefined),
});
export type BaseTextAttributes = z.infer<typeof baseTextAttributes>;

View File

@@ -0,0 +1,3 @@
export * from './attributes';
export * from './text';
export * from './types';

View File

@@ -0,0 +1,472 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { type Signal, signal } from '@preact/signals-core';
import * as Y from 'yjs';
import type { BaseTextAttributes } from './attributes';
import type { DeltaInsert, DeltaOperation, OnTextChange } from './types';
/**
* Text is an abstraction of Y.Text.
* It provides useful methods to manipulate the text content.
*
* @example
* ```ts
* const text = new Text('Hello, world!');
* text.insert(' blocksuite', 7);
* text.delete(7, 1);
* text.format(7, 1, { bold: true });
* text.join(new Text(' blocksuite'));
* text.split(7, 1);
* ```
*
* Text {@link https://docs.yjs.dev/api/delta-format delta} is a format from Y.js.
*
* @category Reactive
*/
export class Text<
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
> {
private readonly _deltas$: Signal<DeltaOperation[]>;
private readonly _length$: Signal<number>;
private _onChange?: OnTextChange;
private readonly _yText: Y.Text;
/**
* Get the text delta as a signal.
*/
get deltas$() {
return this._deltas$;
}
get length() {
return this._length$.value;
}
get yText() {
return this._yText;
}
/**
* @param input - The input can be a string, a Y.Text instance, or an array of DeltaInsert.
*/
constructor(input?: Y.Text | string | DeltaInsert<TextAttributes>[]) {
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(event => {
const isLocal =
!event.transaction.origin ||
!this._yText.doc ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this._yText.doc.clientID;
this._length$.value = this._yText.length;
this._deltas$.value = this._yText.toDelta();
this._onChange?.(this._yText, isLocal);
});
}
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);
}
/**
* Apply a delta to the text.
*
* @param delta - The delta to apply.
*
* @example
* ```ts
* const text = new Text('Hello, world!');
* text.applyDelta([{insert: ' blocksuite', attributes: { bold: true }}]);
* ```
*/
applyDelta(delta: DeltaOperation[]) {
this._transact(() => {
this._yText?.applyDelta(delta);
});
}
/**
* @internal
*/
bind(onChange?: OnTextChange) {
this._onChange = onChange;
}
/**
* Clear the text content.
*/
clear() {
if (!this._yText.length) {
return;
}
this._transact(() => {
this._yText.delete(0, this._yText.length);
});
}
/**
* Clone the text to a new Text instance.
*
* @returns A new Text instance.
*/
clone() {
const text = new Text(this._yText.clone());
text.bind(this._onChange);
return text;
}
/**
* Delete the text content.
*
* @param index - The index to delete.
* @param length - The length to delete.
*/
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 the text content.
*
* @param index - The index to format.
* @param length - The length to format.
* @param format - The format to apply.
*
* @example
* ```ts
* const text = new Text('Hello, world!');
* text.format(7, 1, { bold: true });
* ```
*/
format(index: number, length: number, format: Record<string, unknown>) {
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 at the specified index.
*
* @param content - The content to insert.
* @param index - The index to insert.
*
* @example
* ```ts
* const text = new Text('Hello, world!');
* text.insert(' blocksuite', 7);
* ```
*/
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: ' +
content.length +
', text length: ' +
this._yText.length
);
}
this._transact(() => {
this._yText.insert(index, content, attributes);
});
}
/**
* Join current text with another text.
*
* @param other - The other text to join.
*
* @example
* ```ts
* const text = new Text('Hello, world!');
* const other = new Text(' blocksuite');
* text.join(other);
* ```
*/
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 the text content with a new content.
*
* @param index - The index to replace.
* @param length - The length to replace.
* @param content - The content to replace.
* @param attributes - The attributes to replace.
*
* @example
* ```ts
* const text = new Text('Hello, world!');
* text.replace(7, 1, ' blocksuite');
* ```
*/
replace(
index: number,
length: number,
content: string,
attributes?: Record<string, unknown>
) {
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);
});
}
/**
* Slice the text to a delta.
*
* @param begin - The begin index.
* @param end - The end index.
*
* @returns The delta of the sliced text.
*/
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;
// oxlint-disable-next-line typescript/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;
}
/**
* Split the text into another Text.
*
* @param index - The index to split.
* @param length - The length to split.
*
* @returns The right part of the text.
*
* @example
* ```ts
* const text = new Text('Hello, world!');
* text.split(7, 1);
* ```
*
* 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<TextAttributes>[] = [];
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);
rightText.bind(this._onChange);
return rightText;
}
/**
* Get the text delta.
*
* @returns The delta of the text.
*/
toDelta(): DeltaOperation[] {
return this._yText?.toDelta() || [];
}
/**
* Get the text content as a string.
* In most cases, you should not use this method. It will lose the delta attributes information.
*
* @returns The text content.
*/
toString() {
return this._yText?.toString() || '';
}
}

View File

@@ -0,0 +1,22 @@
import type * as Y from 'yjs';
import type { BaseTextAttributes } from './attributes';
export interface OptionalAttributes {
attributes?: Record<string, any>;
}
export type DeltaOperation = {
insert?: string;
delete?: number;
retain?: number;
} & OptionalAttributes;
export type OnTextChange = (data: Y.Text, isLocal: boolean) => void;
export type DeltaInsert<
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
> = {
insert: string;
attributes?: TextAttributes;
};

View File

@@ -0,0 +1,19 @@
import type { Array as YArray, Map as YMap } from 'yjs';
export type UnRecord = Record<string, unknown>;
export type Native2Y<T> =
T extends Record<string, infer U>
? YMap<U>
: T extends Array<infer U>
? YArray<U>
: T;
export type TransformOptions = {
deep?: boolean;
transform?: (value: unknown, origin: unknown) => unknown;
};
export type ProxyOptions<T> = {
onChange?: (data: T, isLocal: boolean) => void;
};

View File

@@ -0,0 +1,10 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
export class SchemaValidateError extends BlockSuiteError {
constructor(flavour: string, message: string) {
super(
ErrorCode.SchemaValidateError,
`Invalid schema for ${flavour}: ${message}`
);
}
}

View File

@@ -0,0 +1 @@
export { Schema } from './schema.js';

View File

@@ -0,0 +1,325 @@
import { minimatch } from 'minimatch';
import { SCHEMA_NOT_FOUND_MESSAGE } from '../consts.js';
import { BlockSchema, type BlockSchemaType } from '../model/block/zod.js';
import { SchemaValidateError } from './error.js';
/**
* Represents a schema manager for block flavours and their relationships.
* Provides methods to register, validate, and query block schemas.
*/
export class Schema {
/**
* A map storing block flavour names to their corresponding schema definitions.
*/
readonly flavourSchemaMap = new Map<string, BlockSchemaType>();
/**
* Safely validates the schema relationship for a given flavour, parent, and children.
* Returns true if valid, false otherwise (does not throw).
*
* @param flavour - The block flavour to validate.
* @param parentFlavour - The parent block flavour (optional).
* @param childFlavours - The child block flavours (optional).
* @returns True if the schema relationship is valid, false otherwise.
*/
safeValidate = (
flavour: string,
parentFlavour?: string,
childFlavours?: string[]
): boolean => {
try {
this.validate(flavour, parentFlavour, childFlavours);
return true;
} catch {
return false;
}
};
/**
* Retrieves the schema for a given block flavour.
*
* @param flavour - The block flavour name.
* @returns The corresponding BlockSchemaType or undefined if not found.
*/
get(flavour: string) {
return this.flavourSchemaMap.get(flavour);
}
/**
* Validates the schema relationship for a given flavour, parent, and children.
* Throws SchemaValidateError if invalid.
*
* @param flavour - The block flavour to validate.
* @param parentFlavour - The parent block flavour (optional).
* @param childFlavours - The child block flavours (optional).
* @throws {SchemaValidateError} If the schema relationship is invalid.
*/
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,
'None root block must have parent.'
);
}
const parentSchema = this.flavourSchemaMap.get(parentFlavour);
if (!parentSchema) {
throw new SchemaValidateError(parentFlavour, SCHEMA_NOT_FOUND_MESSAGE);
}
this.validateSchema(schema, parentSchema);
validateChildren();
};
/**
* Returns an object mapping each registered flavour to its version number.
*/
get versions() {
return Object.fromEntries(
Array.from(this.flavourSchemaMap.values()).map(
(schema): [string, number] => [schema.model.flavour, schema.version]
)
);
}
/**
* Checks if two flavours match, using minimatch for wildcard support.
*
* @param childFlavour - The child block flavour.
* @param parentFlavour - The parent block flavour.
* @returns True if the flavours match, false otherwise.
*/
private _matchFlavour(childFlavour: string, parentFlavour: string) {
return (
minimatch(childFlavour, parentFlavour) ||
minimatch(parentFlavour, childFlavour)
);
}
/**
* Checks if two values match as either flavours or roles, supporting role syntax (e.g., '@role').
*
* @param childValue - The child value (flavour or role).
* @param parentValue - The parent value (flavour or role).
* @param childRole - The actual role of the child.
* @param parentRole - The actual role of the parent.
* @returns True if the values match as flavours or roles, false otherwise.
*/
private _matchFlavourOrRole(
childValue: string,
parentValue: string,
childRole: string,
parentRole: string
): boolean {
// Check if either value starts with '@' indicating it's a role
const isChildRole = childValue.startsWith('@');
const isParentRole = parentValue.startsWith('@');
// If both are roles, do exact match
if (isChildRole && isParentRole) {
return childValue === parentValue;
}
// If child is role, compare with parent's actual role
if (isChildRole) {
return childValue === `@${parentRole}`;
}
// If parent is role, compare with child's actual role
if (isParentRole) {
return parentValue === `@${childRole}`;
}
// If neither is role, use flavour matching
return this._matchFlavour(childValue, parentValue);
}
/**
* Validates if the parent schema is a valid parent for the child schema.
*
* @param child - The child block schema.
* @param parent - The parent block schema.
* @returns True if the parent is valid for the child, false otherwise.
*/
private _validateParent(
child: BlockSchemaType,
parent: BlockSchemaType
): boolean {
const _childFlavour = child.model.flavour;
const _parentFlavour = parent.model.flavour;
const _childRole = child.model.role;
const _parentRole = parent.model.role;
const childValidFlavourOrRole = child.model.parent || ['*'];
const parentValidFlavourOrRole = parent.model.children || ['*'];
return parentValidFlavourOrRole.some(parentValidFlavourOrRole => {
return childValidFlavourOrRole.some(childValidFlavourOrRole => {
if (
parentValidFlavourOrRole === '*' &&
childValidFlavourOrRole === '*'
) {
return true;
}
if (parentValidFlavourOrRole === '*') {
return this._matchFlavourOrRole(
childValidFlavourOrRole,
_parentFlavour,
_childRole,
_parentRole
);
}
if (childValidFlavourOrRole === '*') {
return this._matchFlavourOrRole(
_childFlavour,
parentValidFlavourOrRole,
_childRole,
_parentRole
);
}
return (
this._matchFlavourOrRole(
_childFlavour,
parentValidFlavourOrRole,
_childRole,
_parentRole
) &&
this._matchFlavourOrRole(
childValidFlavourOrRole,
_parentFlavour,
_childRole,
_parentRole
)
);
});
});
}
/**
* Validates the role relationship between child and parent schemas.
* Throws if the child is a root block but has a parent.
*
* @param child - The child block schema.
* @param parent - The parent block schema.
* @throws {SchemaValidateError} If the child is a root block with a parent.
*/
private _validateRole(child: BlockSchemaType, parent: BlockSchemaType) {
const childRole = child.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}.`
);
}
}
/**
* Checks if the child flavour is valid under the parent flavour.
*
* @param child - The child block flavour name.
* @param parent - The parent block flavour name.
* @returns True if the relationship is valid, false otherwise.
*/
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;
}
}
/**
* Registers an array of block schemas into the schema manager.
*
* @param blockSchema - An array of block schema definitions to register.
* @returns The Schema instance (for chaining).
*/
register(blockSchema: BlockSchemaType[]) {
blockSchema.forEach(schema => {
BlockSchema.parse(schema);
this.flavourSchemaMap.set(schema.model.flavour, schema);
});
return this;
}
/**
* Serializes the schema map to a plain object for JSON output.
*
* @returns An object mapping each flavour to its role, parent, and children.
*/
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,
},
]
)
);
}
/**
* Validates the relationship between a child and parent schema.
* Throws if the relationship is invalid.
*
* @param child - The child block schema.
* @param parent - The parent block schema.
* @throws {SchemaValidateError} If the relationship is invalid.
*/
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}.`
);
}
}
}

View File

@@ -0,0 +1,10 @@
import type { IdGenerator } from '../utils/id-generator.js';
export * from './test-doc.js';
export * from './test-meta.js';
export * from './test-workspace.js';
export function createAutoIncrementIdGenerator(): IdGenerator {
let i = 0;
return () => (i++).toString();
}

View File

@@ -0,0 +1,182 @@
import * as Y from 'yjs';
import type { Doc, GetStoreOptions, Workspace } from '../extension/index.js';
import type { YBlock } from '../model/block/types.js';
import { StoreContainer } from '../model/index.js';
import type { AwarenessStore } from '../yjs/index.js';
import type { TestWorkspace } from './test-workspace.js';
type DocOptions = {
id: string;
collection: Workspace;
doc: Y.Doc;
awarenessStore: AwarenessStore;
};
export class TestDoc implements Doc {
private readonly _collection: Workspace;
private readonly _storeContainer: StoreContainer;
private readonly _initSubDoc = () => {
let subDoc = this.rootDoc.getMap('spaces').get(this.id);
if (!subDoc) {
subDoc = new Y.Doc({
guid: this.id,
});
this.rootDoc.getMap('spaces').set(this.id, subDoc);
this._loaded = true;
} else {
this._loaded = false;
this.rootDoc.on('subdocs', this._onSubdocEvent);
}
return subDoc;
};
private _loaded!: boolean;
private readonly _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;
};
/** Indicate whether the block tree is ready */
private _ready = false;
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: Y.Doc;
get blobSync() {
return this.workspace.blobSync;
}
get workspace() {
return this._collection;
}
get isEmpty() {
return this._yBlocks.size === 0;
}
get loaded() {
return this._loaded;
}
get meta() {
return this.workspace.meta.getDocMeta(this.id);
}
get ready() {
return this._ready;
}
get spaceDoc() {
return this._ySpaceDoc;
}
get yBlocks() {
return this._yBlocks;
}
constructor({ id, collection, doc, awarenessStore }: DocOptions) {
this.id = id;
this.rootDoc = doc;
this.awarenessStore = awarenessStore;
this._ySpaceDoc = this._initSubDoc() as Y.Doc;
this._yBlocks = this._ySpaceDoc.getMap('blocks');
this._collection = collection;
this._storeContainer = new StoreContainer(this);
}
clear() {
this._yBlocks.clear();
}
get removeStore() {
return this._storeContainer.removeStore;
}
private _destroy() {
this._ySpaceDoc.destroy();
this._loaded = false;
}
dispose() {
if (this.ready) {
this._yBlocks.clear();
}
}
getStore({
readonly,
query,
provider,
extensions,
id,
}: GetStoreOptions = {}) {
const storeExtensions = (
this.workspace as TestWorkspace
).storeExtensions.concat(extensions ?? []);
let storeId: string | undefined;
if (id) {
storeId = id;
} else if (readonly !== undefined || query) {
storeId = id;
} else {
storeId = this.spaceDoc.guid;
}
return this._storeContainer.getStore({
id: storeId,
readonly,
query,
provider,
extensions: storeExtensions,
});
}
load(initFn?: () => void): this {
if (this.ready) {
return this;
}
this._ySpaceDoc.load();
initFn?.();
this._ready = true;
return this;
}
remove() {
this._destroy();
this.rootDoc.getMap('spaces').delete(this.id);
}
}

View File

@@ -0,0 +1,179 @@
import { Subject } from 'rxjs';
import type * as Y from 'yjs';
import type {
DocMeta,
DocsPropertiesMeta,
WorkspaceMeta,
} from '../extension/index.js';
import { createYProxy } from '../reactive/proxy.js';
type DocCollectionMetaState = {
pages?: unknown[];
properties?: DocsPropertiesMeta;
name?: string;
avatar?: string;
};
export class TestMeta implements WorkspaceMeta {
private readonly _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();
}
});
};
private _prevDocs = new Set<string>();
protected readonly _proxy: DocCollectionMetaState;
protected readonly _yMap: Y.Map<
DocCollectionMetaState[keyof DocCollectionMetaState]
>;
readonly doc: Y.Doc;
docMetaAdded = new Subject<string>();
docMetaRemoved = new Subject<string>();
docMetaUpdated = new Subject<void>();
readonly id: string = 'meta';
get docMetas() {
if (!this._proxy.pages) {
return [] as DocMeta[];
}
return this._proxy.pages as DocMeta[];
}
get docs() {
return this._proxy.pages;
}
get properties(): DocsPropertiesMeta {
const meta = this._proxy.properties;
if (!meta) {
return {
tags: {
options: [],
},
};
}
return meta;
}
get yDocs() {
return this._yMap.get('pages') as unknown as Y.Array<unknown>;
}
constructor(doc: Y.Doc) {
this.doc = doc;
const map = doc.getMap(this.id) as Y.Map<
DocCollectionMetaState[keyof DocCollectionMetaState]
>;
this._yMap = map;
this._proxy = createYProxy(map);
this._yMap.observeDeep(this._handleDocCollectionMetaEvents);
}
private _handleDocMetaEvent() {
const { docMetas, _prevDocs } = this;
const newDocs = new Set<string>();
docMetas.forEach(docMeta => {
if (!_prevDocs.has(docMeta.id)) {
this.docMetaAdded.next(docMeta.id);
}
newDocs.add(docMeta.id);
});
_prevDocs.forEach(prevDocId => {
const isRemoved = newDocs.has(prevDocId) === false;
if (isRemoved) {
this.docMetaRemoved.next(prevDocId);
}
});
this._prevDocs = newDocs;
this.docMetaUpdated.next();
}
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);
}
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);
}
setProperties(meta: DocsPropertiesMeta) {
this._proxy.properties = meta;
this.docMetaUpdated.next();
}
}

View File

@@ -0,0 +1,231 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { NoopLogger } from '@blocksuite/global/utils';
import {
AwarenessEngine,
type AwarenessSource,
BlobEngine,
type BlobSource,
DocEngine,
type DocSource,
MemoryBlobSource,
NoopDocSource,
} from '@blocksuite/sync';
import { Subject } from 'rxjs';
import { Awareness } from 'y-protocols/awareness.js';
import * as Y from 'yjs';
import type {
Doc,
ExtensionType,
Workspace,
WorkspaceMeta,
} from '../extension/index.js';
import { type IdGenerator, nanoid } from '../utils/id-generator.js';
import { AwarenessStore } from '../yjs/index.js';
import { TestDoc } from './test-doc.js';
import { TestMeta } from './test-meta.js';
export type DocCollectionOptions = {
id?: string;
idGenerator?: IdGenerator;
docSources?: {
main: DocSource;
shadows?: DocSource[];
};
blobSources?: {
main: BlobSource;
shadows?: BlobSource[];
};
awarenessSources?: AwarenessSource[];
};
/**
* @internal
* Test only
* Do not use this in production
*/
export class TestWorkspace implements Workspace {
storeExtensions: ExtensionType[] = [];
readonly awarenessStore: AwarenessStore;
readonly awarenessSync: AwarenessEngine;
readonly blobSync: BlobEngine;
readonly blockCollections = new Map<string, TestDoc>();
readonly doc: Y.Doc;
readonly docSync: DocEngine;
readonly id: string;
readonly idGenerator: IdGenerator;
meta: WorkspaceMeta;
slots = {
docListUpdated: new Subject<void>(),
};
get docs() {
return this.blockCollections;
}
constructor({
id,
idGenerator,
awarenessSources = [],
docSources = {
main: new NoopDocSource(),
},
blobSources = {
main: new MemoryBlobSource(),
},
}: DocCollectionOptions = {}) {
this.id = id || '';
this.doc = new Y.Doc({ guid: id });
this.awarenessStore = new AwarenessStore(new Awareness(this.doc));
const logger = new NoopLogger();
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 = idGenerator ?? nanoid;
this.meta = new TestMeta(this.doc);
this._bindDocMetaEvents();
}
private _bindDocMetaEvents() {
this.meta.docMetaAdded.subscribe(docId => {
const doc = new TestDoc({
id: docId,
collection: this,
doc: this.doc,
awarenessStore: this.awarenessStore,
});
this.blockCollections.set(doc.id, doc);
});
this.meta.docMetaUpdated.subscribe(() => this.slots.docListUpdated.next());
this.meta.docMetaRemoved.subscribe(id => {
const space = this.getBlockCollection(id);
if (!space) return;
this.blockCollections.delete(id);
space.remove();
});
}
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(docId?: string): Doc {
const id = docId ?? this.idGenerator();
if (this._hasDoc(id)) {
throw new BlockSuiteError(
ErrorCode.DocCollectionError,
'doc already exists'
);
}
this.meta.addDocMeta({
id,
title: '',
createDate: Date.now(),
tags: [],
});
return this.getDoc(id) 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): TestDoc | null {
const space = this.docs.get(docId) as TestDoc | undefined;
return space ?? null;
}
getDoc(docId: string): Doc | null {
const collection = this.getBlockCollection(docId);
return collection;
}
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);
}
/**
* 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();
}
}

View File

@@ -0,0 +1,105 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { BlockProps } from '../model';
import type { BlobCRUD } from './type';
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 {
// `blockId` is the key.
readonly uploadingAssetsMap = new Map<
string,
{
blob: Blob;
abortController?: AbortController;
mapInto: (blobId: string) => Partial<BlockProps>;
}
>();
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`
);
}
await this._blob.set(blobId, blob);
}
}

View File

@@ -0,0 +1,89 @@
import { BlockModel } from '../model/block/block-model';
import { type DraftModel, toDraftModel } from '../model/block/draft';
import {
type InternalPrimitives,
internalPrimitives,
} from '../model/block/zod';
import type { AssetsManager } from './assets';
import { fromJSON, toJSON } from './json';
import type { BlockSnapshot } from './type';
export 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>> | 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 | BlockModel) {
let draftModel: DraftModel;
if (model instanceof BlockModel) {
draftModel = toDraftModel(model);
} else {
draftModel = model;
}
return Object.fromEntries(
draftModel.keys.map(key => {
const value = draftModel.props[key as keyof typeof draftModel.props];
return [key, toJSON(value)];
})
);
}
constructor(public readonly transformerConfigs: Map<string, unknown>) {}
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,
};
}
}

View File

@@ -0,0 +1,7 @@
export * from './assets';
export * from './base';
export * from './json';
export * from './middleware';
export * from './slice';
export * from './transformer';
export * from './type';

View File

@@ -0,0 +1,51 @@
import { NATIVE_UNIQ_IDENTIFIER, TEXT_UNIQ_IDENTIFIER } from '../consts';
import { isPureObject } from '../reactive';
import { Boxed } from '../reactive/boxed';
import { Text } from '../reactive/text';
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;
}

View File

@@ -0,0 +1,120 @@
import type { Subject } from 'rxjs';
import type { BlockModel, DraftModel, Store } from '../model/index.js';
import type { AssetsManager } from './assets.js';
import type { Slice } from './slice.js';
import type {
BlockSnapshot,
CollectionInfoSnapshot,
DocCRUD,
DocSnapshot,
SliceSnapshot,
} from './type.js';
export type BeforeImportBlockPayload = {
snapshot: BlockSnapshot;
type: 'block';
parent?: string;
index?: number;
};
export type BeforeImportPayload =
| BeforeImportBlockPayload
| {
snapshot: SliceSnapshot;
type: 'slice';
}
| {
snapshot: DocSnapshot;
type: 'page';
}
| {
snapshot: CollectionInfoSnapshot;
type: 'info';
};
export type BeforeExportPayload =
| {
model: DraftModel;
type: 'block';
}
| {
page: Store;
type: 'page';
}
| {
slice: Slice;
type: 'slice';
}
| {
type: 'info';
};
export type AfterExportPayload =
| {
snapshot: BlockSnapshot;
type: 'block';
model: DraftModel;
parent?: string;
index?: number;
}
| {
snapshot: DocSnapshot;
type: 'page';
page: Store;
}
| {
snapshot: SliceSnapshot;
type: 'slice';
slice: Slice;
}
| {
snapshot: CollectionInfoSnapshot;
type: 'info';
};
export type AfterImportBlockPayload = {
snapshot: BlockSnapshot;
type: 'block';
model: BlockModel;
parent?: string;
index?: number;
};
export type AfterImportPayload =
| AfterImportBlockPayload
| {
snapshot: DocSnapshot;
type: 'page';
page: Store;
}
| {
snapshot: SliceSnapshot;
type: 'slice';
slice: Slice;
}
| {
snapshot: CollectionInfoSnapshot;
type: 'info';
};
export type TransformerSlots = {
beforeImport: Subject<BeforeImportPayload>;
afterImport: Subject<AfterImportPayload>;
beforeExport: Subject<BeforeExportPayload>;
afterExport: Subject<AfterExportPayload>;
};
type TransformerMiddlewareOptions = {
assetsManager: AssetsManager;
slots: TransformerSlots;
docCRUD: DocCRUD;
adapterConfigs: Map<string, unknown>;
transformerConfigs: Map<string, unknown>;
};
type TransformerMiddlewareCleanup = () => void;
export type TransformerMiddleware = (
options: TransformerMiddlewareOptions
) => void | TransformerMiddlewareCleanup;

View File

@@ -0,0 +1,39 @@
import { BlockModel } from '../model/block/block-model.js';
import { type DraftModel, toDraftModel } from '../model/block/draft.js';
import type { Store } from '../model/store/store.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: Store, models: DraftModel[] | BlockModel[]) {
const draftModels = models.map(model => {
if (model instanceof BlockModel) {
return toDraftModel(model);
}
return model;
});
return new Slice({
content: draftModels,
workspaceId: doc.workspace.id,
pageId: doc.id,
});
}
}

View File

@@ -0,0 +1,657 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { nextTick } from '@blocksuite/global/utils';
import { Subject } from 'rxjs';
import { BlockModel } from '../model/block/block-model.js';
import { type DraftModel, toDraftModel } from '../model/block/draft.js';
import type { BlockSchemaType } from '../model/block/zod.js';
import type { Store } from '../model/store/store.js';
import type { Schema } from '../schema/schema.js';
import { AssetsManager } from './assets.js';
import { BaseBlockTransformer } from './base.js';
import type {
AfterExportPayload,
AfterImportPayload,
BeforeExportPayload,
BeforeImportPayload,
TransformerMiddleware,
TransformerSlots,
} from './middleware.js';
import { Slice } from './slice.js';
import type {
BlobCRUD,
BlockSnapshot,
DocCRUD,
DocSnapshot,
SliceSnapshot,
} from './type.js';
import {
BlockSnapshotSchema,
DocSnapshotSchema,
SliceSnapshotSchema,
} from './type.js';
export type TransformerOptions = {
schema: Schema;
blobCRUD: BlobCRUD;
docCRUD: DocCRUD;
middlewares?: TransformerMiddleware[];
};
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 Transformer {
private readonly _adapterConfigs = new Map<string, string>();
private readonly _transformerConfigs = new Map<string, unknown>();
private readonly _assetsManager: AssetsManager;
private readonly _schema: Schema;
private readonly _docCRUD: DocCRUD;
private readonly _disposables: DisposableGroup = new DisposableGroup();
private readonly _slots: TransformerSlots = {
beforeImport: new Subject<BeforeImportPayload>(),
afterImport: new Subject<AfterImportPayload>(),
beforeExport: new Subject<BeforeExportPayload>(),
afterExport: new Subject<AfterExportPayload>(),
};
blockToSnapshot = (
model: DraftModel | BlockModel
): BlockSnapshot | undefined => {
try {
const draftModel =
model instanceof BlockModel ? toDraftModel(model) : model;
const snapshot = this._blockToSnapshot(draftModel);
if (!snapshot) {
return;
}
BlockSnapshotSchema.parse(snapshot);
return snapshot;
} catch (error) {
console.error(`Error when transforming block to snapshot:`);
console.error(error);
return;
}
};
docToSnapshot = (doc: Store): DocSnapshot | undefined => {
try {
this._slots.beforeExport.next({
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(toDraftModel(rootModel));
if (!blocks) {
return;
}
const docSnapshot: DocSnapshot = {
type: 'page',
meta,
blocks,
};
this._slots.afterExport.next({
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.next({
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.next({
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: Store,
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<Store | undefined> => {
try {
this._slots.beforeImport.next({
type: 'page',
snapshot,
});
DocSnapshotSchema.parse(snapshot);
const { meta, blocks } = snapshot;
const doc = this.docCRUD.create(meta.id);
doc.load();
await this.snapshotToBlock(blocks, doc);
this._slots.afterImport.next({
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: Store,
parent?: string,
index?: number
): Promise<Slice | undefined> => {
try {
SliceSnapshotSchema.parse(snapshot);
this._slots.beforeImport.next({
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);
const first = content[0];
// check if the slice is already in the doc
if (first && doc.hasBlock(first.id)) {
// if the slice is already in the doc, we need to move the blocks instead of adding them
const models = content
.map(block => doc.getBlock(block.id)?.model)
.filter(Boolean) as BlockModel[];
const parentModel = parent ? doc.getBlock(parent)?.model : undefined;
if (!parentModel) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
'Parent block not found in doc when moving slice'
);
}
const targetSibling =
index !== undefined ? parentModel.children[index] : null;
doc.moveBlocks(models, parentModel, targetSibling);
} else {
await this._insertBlockTree(blockTree.children, doc, parent, index);
}
const contentBlocks = blockTree.children
.map(tree => doc.getModelById(tree.draft.id))
.filter((x): x is BlockModel => x !== null)
.map(model => toDraftModel(model));
const slice = new Slice({
content: contentBlocks,
workspaceId,
pageId,
});
this._slots.afterImport.next({
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 schema() {
return this._schema;
}
get docCRUD() {
return this._docCRUD;
}
constructor({
blobCRUD,
schema,
docCRUD,
middlewares = [],
}: TransformerOptions) {
this._assetsManager = new AssetsManager({ blob: blobCRUD });
this._schema = schema;
this._docCRUD = docCRUD;
middlewares.forEach(middleware => {
const cleanup = middleware({
slots: this._slots,
docCRUD: this._docCRUD,
assetsManager: this._assetsManager,
adapterConfigs: this._adapterConfigs,
transformerConfigs: this._transformerConfigs,
});
if (cleanup) {
this._disposables.add(cleanup);
}
});
}
private _blockToSnapshot(model: DraftModel): BlockSnapshot | null {
this._slots.beforeExport.next({
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);
})
.filter(Boolean) as BlockSnapshot[];
const snapshot: BlockSnapshot = {
type: 'block',
...snapshotLeaf,
children,
};
this._slots.afterExport.next({
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 unknown as DraftModel;
} catch (error) {
console.error(`Error when transforming snapshot to model data:`);
console.error(error);
return;
}
}
private _exportDocMeta(doc: Store): 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 _getSchema(flavour: string) {
const schema = this.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?.(this._transformerConfigs) ??
new BaseBlockTransformer(this._transformerConfigs)
);
}
private async _insertBlockTree(
nodes: DraftBlockTreeNode[],
doc: Store,
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, props } = draft;
const actualIndex =
startIndex !== undefined ? startIndex + index : undefined;
doc.addBlock(flavour, { id, ...props }, parentId, actualIndex);
const model = doc.getBlock(id)?.model;
if (!model) {
throw new BlockSuiteError(
ErrorCode.TransformerError,
`Block not found by id ${id}`
);
}
this._slots.afterImport.next({
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: Store,
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.next({
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();
}
[Symbol.dispose]() {
this._disposables.dispose();
this._assetsManager.cleanup();
}
}

View File

@@ -0,0 +1,74 @@
import { z } from 'zod';
import type { DocMeta, DocsPropertiesMeta } from '../extension';
import type { Store } from '../model';
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 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,
});
export 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[];
}
export interface DocCRUD {
create: (id: string) => Store;
get: (id: string) => Store | null;
delete: (id: string) => void;
}

View File

@@ -0,0 +1,12 @@
import { uuidv4 as uuidv4IdGenerator } from 'lib0/random.js';
import { nanoid as nanoidGenerator } from 'nanoid';
export type IdGenerator = () => string;
export const uuidv4: IdGenerator = () => {
return uuidv4IdGenerator();
};
export const nanoid: IdGenerator = () => {
return nanoidGenerator(10);
};

View File

@@ -0,0 +1,69 @@
import type { Awareness } from 'y-protocols/awareness.js';
export interface UserInfo {
name: string;
}
type UserSelection = Array<Record<string, unknown>>;
// Raw JSON state in awareness CRDT
export type RawAwarenessState = {
user?: UserInfo;
color?: string;
selectionV2: Record<string, UserSelection>;
};
export interface AwarenessEvent {
id: number;
type: 'add' | 'update' | 'remove';
state?: RawAwarenessState;
}
export class AwarenessStore {
readonly awareness: Awareness;
constructor(awareness: Awareness) {
this.awareness = awareness;
this.awareness.setLocalStateField('selectionV2', {});
}
destroy() {
this.awareness.destroy();
}
getLocalSelection(
selectionManagerId: string
): ReadonlyArray<Record<string, unknown>> {
return (
(this.awareness.getLocalState()?.selectionV2 ?? {})[selectionManagerId] ??
[]
);
}
getStates(): Map<number, RawAwarenessState> {
return this.awareness.getStates() as Map<number, RawAwarenessState>;
}
getLocalState(): RawAwarenessState {
return this.awareness.getLocalState() as RawAwarenessState;
}
setLocalState(state: RawAwarenessState): void {
this.awareness.setLocalState(state);
}
setLocalStateField<Field extends keyof RawAwarenessState>(
field: Field,
value: RawAwarenessState[Field]
): void {
this.awareness.setLocalStateField(field, value);
}
setLocalSelection(selectionManagerId: string, selection: UserSelection) {
const oldSelection = this.awareness.getLocalState()?.selectionV2 ?? {};
this.awareness.setLocalStateField('selectionV2', {
...oldSelection,
[selectionManagerId]: selection,
});
}
}

View File

@@ -0,0 +1,2 @@
export * from './awareness.js';
export * from './utils.js';

View File

@@ -0,0 +1,11 @@
import type { Doc as YDoc } from 'yjs';
export type SubdocEvent = {
loaded: Set<YDoc>;
removed: Set<YDoc>;
added: Set<YDoc>;
};
export interface StackItem {
meta: Map<'selection-state', unknown>;
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [{ "path": "../global" }, { "path": "../sync" }]
}

View File

@@ -0,0 +1,3 @@
{
"entryPoints": ["src/index.ts"]
}

View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/store',
},
/**
* Custom handler for console.log in tests.
*
* Return `false` to ignore the log.
*/
onConsoleLog(log, type) {
if (log.includes('https://lit.dev/msg/dev-mode')) {
return false;
}
console.warn(`Unexpected ${type} log`, log);
throw new Error(log);
},
},
});