snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
7
blocksuite/framework/store/README.md
Normal file
7
blocksuite/framework/store/README.md
Normal 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.
|
||||
42
blocksuite/framework/store/package.json
Normal file
42
blocksuite/framework/store/package.json
Normal 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"
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
77
blocksuite/framework/store/src/__tests__/assets.unit.spec.ts
Normal file
77
blocksuite/framework/store/src/__tests__/assets.unit.spec.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
550
blocksuite/framework/store/src/__tests__/block.unit.spec.ts
Normal file
550
blocksuite/framework/store/src/__tests__/block.unit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
850
blocksuite/framework/store/src/__tests__/collection.unit.spec.ts
Normal file
850
blocksuite/framework/store/src/__tests__/collection.unit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
324
blocksuite/framework/store/src/__tests__/doc.unit.spec.ts
Normal file
324
blocksuite/framework/store/src/__tests__/doc.unit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
186
blocksuite/framework/store/src/__tests__/schema.unit.spec.ts
Normal file
186
blocksuite/framework/store/src/__tests__/schema.unit.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
blocksuite/framework/store/src/__tests__/test-schema.ts
Normal file
103
blocksuite/framework/store/src/__tests__/test-schema.ts
Normal 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);
|
||||
115
blocksuite/framework/store/src/__tests__/test-utils-dom.ts
Normal file
115
blocksuite/framework/store/src/__tests__/test-utils-dom.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
278
blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts
Normal file
278
blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts
Normal 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);
|
||||
});
|
||||
159
blocksuite/framework/store/src/adapter/assets.ts
Normal file
159
blocksuite/framework/store/src/adapter/assets.ts
Normal 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)}`;
|
||||
}
|
||||
324
blocksuite/framework/store/src/adapter/base.ts
Normal file
324
blocksuite/framework/store/src/adapter/base.ts
Normal 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>();
|
||||
}
|
||||
}
|
||||
119
blocksuite/framework/store/src/adapter/context.ts
Normal file
119
blocksuite/framework/store/src/adapter/context.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
blocksuite/framework/store/src/adapter/index.ts
Normal file
3
blocksuite/framework/store/src/adapter/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './assets.js';
|
||||
export * from './base.js';
|
||||
export * from './context.js';
|
||||
7
blocksuite/framework/store/src/consts.ts
Normal file
7
blocksuite/framework/store/src/consts.ts
Normal 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']);
|
||||
134
blocksuite/framework/store/src/extension/extension.ts
Normal file
134
blocksuite/framework/store/src/extension/extension.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './history-extension';
|
||||
6
blocksuite/framework/store/src/extension/index.ts
Normal file
6
blocksuite/framework/store/src/extension/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './extension';
|
||||
export * from './history';
|
||||
export * from './schema';
|
||||
export * from './selection';
|
||||
export * from './store-extension';
|
||||
export * from './workspace';
|
||||
20
blocksuite/framework/store/src/extension/schema.ts
Normal file
20
blocksuite/framework/store/src/extension/schema.ts
Normal 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
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
46
blocksuite/framework/store/src/extension/selection/base.ts
Normal file
46
blocksuite/framework/store/src/extension/selection/base.ts
Normal 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>;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './base';
|
||||
export * from './identifier';
|
||||
export * from './selection-extension';
|
||||
export * from './types';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
61
blocksuite/framework/store/src/extension/store-extension.ts
Normal file
61
blocksuite/framework/store/src/extension/store-extension.ts
Normal 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;
|
||||
}
|
||||
38
blocksuite/framework/store/src/extension/workspace/doc.ts
Normal file
38
blocksuite/framework/store/src/extension/workspace/doc.ts
Normal 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');
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './doc';
|
||||
export * from './workspace';
|
||||
export * from './workspace-meta';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
23
blocksuite/framework/store/src/index.ts
Normal file
23
blocksuite/framework/store/src/index.ts
Normal 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;
|
||||
158
blocksuite/framework/store/src/model/block/block-model.ts
Normal file
158
blocksuite/framework/store/src/model/block/block-model.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
65
blocksuite/framework/store/src/model/block/block.ts
Normal file
65
blocksuite/framework/store/src/model/block/block.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
blocksuite/framework/store/src/model/block/draft.ts
Normal file
42
blocksuite/framework/store/src/model/block/draft.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
5
blocksuite/framework/store/src/model/block/index.ts
Normal file
5
blocksuite/framework/store/src/model/block/index.ts
Normal 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';
|
||||
391
blocksuite/framework/store/src/model/block/sync-controller.ts
Normal file
391
blocksuite/framework/store/src/model/block/sync-controller.ts
Normal 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;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
23
blocksuite/framework/store/src/model/block/types.ts
Normal file
23
blocksuite/framework/store/src/model/block/types.ts
Normal 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;
|
||||
117
blocksuite/framework/store/src/model/block/zod.ts
Normal file
117
blocksuite/framework/store/src/model/block/zod.ts
Normal 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;
|
||||
}
|
||||
3
blocksuite/framework/store/src/model/index.ts
Normal file
3
blocksuite/framework/store/src/model/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './block/index.js';
|
||||
export * from './store/index.js';
|
||||
export * from './store-container.js';
|
||||
86
blocksuite/framework/store/src/model/store-container.ts
Normal file
86
blocksuite/framework/store/src/model/store-container.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
397
blocksuite/framework/store/src/model/store/crud.ts
Normal file
397
blocksuite/framework/store/src/model/store/crud.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
5
blocksuite/framework/store/src/model/store/identifier.ts
Normal file
5
blocksuite/framework/store/src/model/store/identifier.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
|
||||
import type { Store } from './store';
|
||||
|
||||
export const StoreIdentifier = createIdentifier<Store>('Store');
|
||||
3
blocksuite/framework/store/src/model/store/index.ts
Normal file
3
blocksuite/framework/store/src/model/store/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './identifier.js';
|
||||
export * from './query.js';
|
||||
export * from './store.js';
|
||||
82
blocksuite/framework/store/src/model/store/query.ts
Normal file
82
blocksuite/framework/store/src/model/store/query.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1298
blocksuite/framework/store/src/model/store/store.ts
Normal file
1298
blocksuite/framework/store/src/model/store/store.ts
Normal file
File diff suppressed because it is too large
Load Diff
36
blocksuite/framework/store/src/model/store/utils.ts
Normal file
36
blocksuite/framework/store/src/model/store/utils.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
137
blocksuite/framework/store/src/reactive/boxed.ts
Normal file
137
blocksuite/framework/store/src/reactive/boxed.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
167
blocksuite/framework/store/src/reactive/flat-native-y/index.ts
Normal file
167
blocksuite/framework/store/src/reactive/flat-native-y/index.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
203
blocksuite/framework/store/src/reactive/flat-native-y/proxy.ts
Normal file
203
blocksuite/framework/store/src/reactive/flat-native-y/proxy.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
8
blocksuite/framework/store/src/reactive/index.ts
Normal file
8
blocksuite/framework/store/src/reactive/index.ts
Normal 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';
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
4
blocksuite/framework/store/src/reactive/memory.ts
Normal file
4
blocksuite/framework/store/src/reactive/memory.ts
Normal 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>>();
|
||||
74
blocksuite/framework/store/src/reactive/native-y.ts
Normal file
74
blocksuite/framework/store/src/reactive/native-y.ts
Normal 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);
|
||||
}
|
||||
390
blocksuite/framework/store/src/reactive/proxy.ts
Normal file
390
blocksuite/framework/store/src/reactive/proxy.ts
Normal 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;
|
||||
}
|
||||
17
blocksuite/framework/store/src/reactive/stash-pop.ts
Normal file
17
blocksuite/framework/store/src/reactive/stash-pop.ts
Normal 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);
|
||||
}
|
||||
12
blocksuite/framework/store/src/reactive/text/attributes.ts
Normal file
12
blocksuite/framework/store/src/reactive/text/attributes.ts
Normal 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>;
|
||||
3
blocksuite/framework/store/src/reactive/text/index.ts
Normal file
3
blocksuite/framework/store/src/reactive/text/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './attributes';
|
||||
export * from './text';
|
||||
export * from './types';
|
||||
472
blocksuite/framework/store/src/reactive/text/text.ts
Normal file
472
blocksuite/framework/store/src/reactive/text/text.ts
Normal 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() || '';
|
||||
}
|
||||
}
|
||||
22
blocksuite/framework/store/src/reactive/text/types.ts
Normal file
22
blocksuite/framework/store/src/reactive/text/types.ts
Normal 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;
|
||||
};
|
||||
19
blocksuite/framework/store/src/reactive/types.ts
Normal file
19
blocksuite/framework/store/src/reactive/types.ts
Normal 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;
|
||||
};
|
||||
10
blocksuite/framework/store/src/schema/error.ts
Normal file
10
blocksuite/framework/store/src/schema/error.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
1
blocksuite/framework/store/src/schema/index.ts
Normal file
1
blocksuite/framework/store/src/schema/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Schema } from './schema.js';
|
||||
325
blocksuite/framework/store/src/schema/schema.ts
Normal file
325
blocksuite/framework/store/src/schema/schema.ts
Normal 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}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
blocksuite/framework/store/src/test/index.ts
Normal file
10
blocksuite/framework/store/src/test/index.ts
Normal 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();
|
||||
}
|
||||
182
blocksuite/framework/store/src/test/test-doc.ts
Normal file
182
blocksuite/framework/store/src/test/test-doc.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
179
blocksuite/framework/store/src/test/test-meta.ts
Normal file
179
blocksuite/framework/store/src/test/test-meta.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
231
blocksuite/framework/store/src/test/test-workspace.ts
Normal file
231
blocksuite/framework/store/src/test/test-workspace.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
105
blocksuite/framework/store/src/transformer/assets.ts
Normal file
105
blocksuite/framework/store/src/transformer/assets.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
89
blocksuite/framework/store/src/transformer/base.ts
Normal file
89
blocksuite/framework/store/src/transformer/base.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
blocksuite/framework/store/src/transformer/index.ts
Normal file
7
blocksuite/framework/store/src/transformer/index.ts
Normal 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';
|
||||
51
blocksuite/framework/store/src/transformer/json.ts
Normal file
51
blocksuite/framework/store/src/transformer/json.ts
Normal 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;
|
||||
}
|
||||
120
blocksuite/framework/store/src/transformer/middleware.ts
Normal file
120
blocksuite/framework/store/src/transformer/middleware.ts
Normal 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;
|
||||
39
blocksuite/framework/store/src/transformer/slice.ts
Normal file
39
blocksuite/framework/store/src/transformer/slice.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
657
blocksuite/framework/store/src/transformer/transformer.ts
Normal file
657
blocksuite/framework/store/src/transformer/transformer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
74
blocksuite/framework/store/src/transformer/type.ts
Normal file
74
blocksuite/framework/store/src/transformer/type.ts
Normal 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;
|
||||
}
|
||||
12
blocksuite/framework/store/src/utils/id-generator.ts
Normal file
12
blocksuite/framework/store/src/utils/id-generator.ts
Normal 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);
|
||||
};
|
||||
69
blocksuite/framework/store/src/yjs/awareness.ts
Normal file
69
blocksuite/framework/store/src/yjs/awareness.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
2
blocksuite/framework/store/src/yjs/index.ts
Normal file
2
blocksuite/framework/store/src/yjs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './awareness.js';
|
||||
export * from './utils.js';
|
||||
11
blocksuite/framework/store/src/yjs/utils.ts
Normal file
11
blocksuite/framework/store/src/yjs/utils.ts
Normal 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>;
|
||||
}
|
||||
10
blocksuite/framework/store/tsconfig.json
Normal file
10
blocksuite/framework/store/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [{ "path": "../global" }, { "path": "../sync" }]
|
||||
}
|
||||
3
blocksuite/framework/store/typedoc.json
Normal file
3
blocksuite/framework/store/typedoc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"entryPoints": ["src/index.ts"]
|
||||
}
|
||||
28
blocksuite/framework/store/vitest.config.ts
Normal file
28
blocksuite/framework/store/vitest.config.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user