feat(editor): flat block data (#9854)

Flat block data.

A new block type to flatten the block data

```typescript
// For developers
type Model = {
  blocks: Record<string, {
    flavour: string;
    cells: Record<string, {
      rowId: string;
      colId: string;
      text: Text;
    }>;
    cols: Record<string, {
      align: string;
    }>
    rows: Record<string, {
      backgroundColor: string;
    }>
  }>
}

// How it's saved in yjs
const yData = {
  blocks: {
    'blockId1': {
      flavour: 'affine:table',
      'prop:rows:row1:backgroundColor': 'white',
      'prop:cols:col1:align': 'left',
      'prop:cells:cell1:rowId': 'row1',
      'prop:cells:cell1:colId': 'col1',
      'prop:cells:cell1:text': YText,
      prop:children: []
    },
  }
}
```
This commit is contained in:
Saul-Mirone
2025-01-25 12:57:21 +00:00
parent 9c5375ca06
commit 1858947e0c
17 changed files with 1189 additions and 389 deletions

View File

@@ -39,13 +39,29 @@ const tableSchema = defineBlockSchema({
version: 1,
},
});
const flatTableSchema = defineBlockSchema({
flavour: 'flat-table',
props: internal => ({
title: internal.Text(),
cols: { internal: { color: 'white' } } as Record<string, { color: string }>,
rows: {} as Record<string, { color: string }>,
labels: [] as Array<string>,
}),
metadata: {
role: 'content',
version: 1,
isFlatData: true,
},
});
type RootModel = SchemaToModel<typeof pageSchema>;
type TableModel = SchemaToModel<typeof tableSchema>;
type FlatTableModel = SchemaToModel<typeof flatTableSchema>;
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
const schema = new Schema();
schema.register([pageSchema, tableSchema]);
schema.register([pageSchema, tableSchema, flatTableSchema]);
return { id: 'test-collection', idGenerator, schema };
}
@@ -181,24 +197,29 @@ describe('block model should has signal props', () => {
yBlock.set('sys:flavour', 'page');
yBlock.set('sys:children', new Y.Array());
const block = new Block(doc.schema, yBlock, doc);
const onChange = vi.fn();
const block = new Block(doc.schema, yBlock, doc, { onChange });
const model = block.model as RootModel;
expect(model.count).toBe(0);
model.stash('count');
onChange.mockClear();
model.count = 1;
expect(model.count$.value).toBe(1);
expect(yBlock.get('prop:count')).toBe(0);
expect(onChange).toHaveBeenCalledTimes(1);
model.count$.value = 2;
expect(model.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.count).toBe(2);
expect(model.count$.value).toBe(2);
expect(onChange).toHaveBeenCalledTimes(3);
model.stash('count');
yBlock.set('prop:count', 3);
@@ -361,3 +382,147 @@ test('deep sync', () => {
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',
expect.anything()
);
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',
expect.anything()
);
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',
expect.anything()
);
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',
expect.anything()
);
onChange.mockClear();
model.props.labels$.value = ['test2'];
expect(getLabels().toJSON()).toEqual(['test2']);
expect(onChange).toHaveBeenCalledWith(
expect.anything(),
'labels',
expect.anything()
);
});
test('stash and pop', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'flat-table');
yBlock.set('sys:children', new Y.Array());
const onColUpdated = vi.fn();
const onChange = vi.fn();
const block = new Block(doc.schema, yBlock, doc, { onChange });
const model = block.model as FlatTableModel;
model.props.cols$.subscribe(onColUpdated);
onChange.mockClear();
onColUpdated.mockClear();
model.props.cols = {
a: { color: 'red' },
};
expect(yBlock.get('prop:cols.a.color')).toBe('red');
expect(model.props.cols$.value.a.color).toBe('red');
expect(onColUpdated).toHaveBeenCalledTimes(1);
onChange.mockClear();
onColUpdated.mockClear();
model.stash('cols');
model.props.cols.a.color = 'blue';
expect(yBlock.get('prop:cols.a.color')).toBe('red');
expect(model.props.cols$.value.a.color).toBe('blue');
expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1);
model.pop('cols');
expect(yBlock.get('prop:cols.a.color')).toBe('blue');
expect(onColUpdated).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,177 +1,237 @@
import { Slot } from '@blocksuite/global/utils';
import { describe, expect, test } from 'vitest';
import * as Y from 'yjs';
import { ReactiveFlatYMap } from '../reactive/flat-native-y.js';
import type { Text } from '../reactive/index.js';
import { Boxed, createYProxy, popProp, stashProp } from '../reactive/index.js';
describe('blocksuite yjs', () => {
describe('array', () => {
test('proxy', () => {
const ydoc = new Y.Doc();
const arr = ydoc.getArray('arr');
arr.push([0]);
describe('array', () => {
test('proxy', () => {
const ydoc = new Y.Doc();
const arr = ydoc.getArray('arr');
arr.push([0]);
const proxy = createYProxy(arr) as unknown[];
expect(arr.get(0)).toBe(0);
const proxy = createYProxy(arr) as unknown[];
expect(arr.get(0)).toBe(0);
proxy.push(1);
expect(arr.get(1)).toBe(1);
expect(arr.length).toBe(2);
proxy.push(1);
expect(arr.get(1)).toBe(1);
expect(arr.length).toBe(2);
proxy.splice(1, 1);
expect(arr.length).toBe(1);
proxy.splice(1, 1);
expect(arr.length).toBe(1);
proxy[0] = 2;
expect(arr.length).toBe(1);
});
});
describe('object', () => {
test('deep', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const obj = new Y.Map();
obj.set('foo', 1);
map.set('obj', obj);
map.set('num', 0);
const map2 = new Y.Map();
obj.set('map', map2);
map2.set('foo', 40);
const proxy = createYProxy<Record<string, any>>(map);
expect(proxy.num).toBe(0);
expect(proxy.obj.foo).toBe(1);
expect(proxy.obj.map.foo).toBe(40);
proxy.obj.bar = 100;
expect(obj.get('bar')).toBe(100);
proxy.obj2 = { foo: 2, bar: { num: 3 } };
expect(map.get('obj2')).toBeInstanceOf(Y.Map);
// @ts-expect-error 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);
});
proxy[0] = 2;
expect(arr.length).toBe(1);
});
});
describe('object', () => {
test('deep', () => {
const ydoc = new Y.Doc();
const map = ydoc.getMap('map');
const obj = new Y.Map();
obj.set('foo', 1);
map.set('obj', obj);
map.set('num', 0);
const map2 = new Y.Map();
obj.set('map', map2);
map2.set('foo', 40);
const proxy = createYProxy<Record<string, any>>(map);
expect(proxy.num).toBe(0);
expect(proxy.obj.foo).toBe(1);
expect(proxy.obj.map.foo).toBe(40);
proxy.obj.bar = 100;
expect(obj.get('bar')).toBe(100);
proxy.obj2 = { foo: 2, bar: { num: 3 } };
expect(map.get('obj2')).toBeInstanceOf(Y.Map);
// @ts-expect-error 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 Slot());
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);
});