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:
@@ -39,13 +39,29 @@ const tableSchema = defineBlockSchema({
|
|||||||
version: 1,
|
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 RootModel = SchemaToModel<typeof pageSchema>;
|
||||||
type TableModel = SchemaToModel<typeof tableSchema>;
|
type TableModel = SchemaToModel<typeof tableSchema>;
|
||||||
|
type FlatTableModel = SchemaToModel<typeof flatTableSchema>;
|
||||||
|
|
||||||
function createTestOptions() {
|
function createTestOptions() {
|
||||||
const idGenerator = createAutoIncrementIdGenerator();
|
const idGenerator = createAutoIncrementIdGenerator();
|
||||||
const schema = new Schema();
|
const schema = new Schema();
|
||||||
schema.register([pageSchema, tableSchema]);
|
schema.register([pageSchema, tableSchema, flatTableSchema]);
|
||||||
return { id: 'test-collection', idGenerator, schema };
|
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:flavour', 'page');
|
||||||
yBlock.set('sys:children', new Y.Array());
|
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;
|
const model = block.model as RootModel;
|
||||||
|
|
||||||
expect(model.count).toBe(0);
|
expect(model.count).toBe(0);
|
||||||
model.stash('count');
|
model.stash('count');
|
||||||
|
|
||||||
|
onChange.mockClear();
|
||||||
model.count = 1;
|
model.count = 1;
|
||||||
expect(model.count$.value).toBe(1);
|
expect(model.count$.value).toBe(1);
|
||||||
expect(yBlock.get('prop:count')).toBe(0);
|
expect(yBlock.get('prop:count')).toBe(0);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
model.count$.value = 2;
|
model.count$.value = 2;
|
||||||
expect(model.count).toBe(2);
|
expect(model.count).toBe(2);
|
||||||
expect(yBlock.get('prop:count')).toBe(0);
|
expect(yBlock.get('prop:count')).toBe(0);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
model.pop('count');
|
model.pop('count');
|
||||||
expect(yBlock.get('prop:count')).toBe(2);
|
expect(yBlock.get('prop:count')).toBe(2);
|
||||||
expect(model.count).toBe(2);
|
expect(model.count).toBe(2);
|
||||||
expect(model.count$.value).toBe(2);
|
expect(model.count$.value).toBe(2);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
model.stash('count');
|
model.stash('count');
|
||||||
yBlock.set('prop:count', 3);
|
yBlock.set('prop:count', 3);
|
||||||
@@ -361,3 +382,147 @@ test('deep sync', () => {
|
|||||||
expect(onPropsUpdated).toHaveBeenCalledTimes(1);
|
expect(onPropsUpdated).toHaveBeenCalledTimes(1);
|
||||||
expect(onRowsUpdated).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,177 +1,237 @@
|
|||||||
|
import { Slot } from '@blocksuite/global/utils';
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import { ReactiveFlatYMap } from '../reactive/flat-native-y.js';
|
||||||
import type { Text } from '../reactive/index.js';
|
import type { Text } from '../reactive/index.js';
|
||||||
import { Boxed, createYProxy, popProp, stashProp } from '../reactive/index.js';
|
import { Boxed, createYProxy, popProp, stashProp } from '../reactive/index.js';
|
||||||
|
|
||||||
describe('blocksuite yjs', () => {
|
describe('array', () => {
|
||||||
describe('array', () => {
|
test('proxy', () => {
|
||||||
test('proxy', () => {
|
const ydoc = new Y.Doc();
|
||||||
const ydoc = new Y.Doc();
|
const arr = ydoc.getArray('arr');
|
||||||
const arr = ydoc.getArray('arr');
|
arr.push([0]);
|
||||||
arr.push([0]);
|
|
||||||
|
|
||||||
const proxy = createYProxy(arr) as unknown[];
|
const proxy = createYProxy(arr) as unknown[];
|
||||||
expect(arr.get(0)).toBe(0);
|
expect(arr.get(0)).toBe(0);
|
||||||
|
|
||||||
proxy.push(1);
|
proxy.push(1);
|
||||||
expect(arr.get(1)).toBe(1);
|
expect(arr.get(1)).toBe(1);
|
||||||
expect(arr.length).toBe(2);
|
expect(arr.length).toBe(2);
|
||||||
|
|
||||||
proxy.splice(1, 1);
|
proxy.splice(1, 1);
|
||||||
expect(arr.length).toBe(1);
|
expect(arr.length).toBe(1);
|
||||||
|
|
||||||
proxy[0] = 2;
|
proxy[0] = 2;
|
||||||
expect(arr.length).toBe(1);
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ export class BlockModel<
|
|||||||
|
|
||||||
yBlock!: YBlock;
|
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 {
|
get flavour(): string {
|
||||||
return this.schema.model.flavour;
|
return this.schema.model.flavour;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Schema } from '../../schema/index.js';
|
import type { Schema } from '../../schema/index.js';
|
||||||
import type { Store } from '../store/store.js';
|
import type { Store } from '../store/store.js';
|
||||||
|
import { FlatSyncController } from './flat-sync-controller.js';
|
||||||
import { SyncController } from './sync-controller.js';
|
import { SyncController } from './sync-controller.js';
|
||||||
import type { BlockOptions, YBlock } from './types.js';
|
import type { BlockOptions, YBlock } from './types.js';
|
||||||
|
|
||||||
export type BlockViewType = 'bypass' | 'display' | 'hidden';
|
export type BlockViewType = 'bypass' | 'display' | 'hidden';
|
||||||
|
|
||||||
export class Block {
|
export class Block {
|
||||||
private readonly _syncController: SyncController;
|
private readonly _syncController: SyncController | FlatSyncController;
|
||||||
|
|
||||||
blockViewType: BlockViewType = 'display';
|
blockViewType: BlockViewType = 'display';
|
||||||
|
|
||||||
@@ -45,6 +46,17 @@ export class Block {
|
|||||||
: (key: string, value: unknown) => {
|
: (key: string, value: unknown) => {
|
||||||
options.onChange?.(this, key, value);
|
options.onChange?.(this, key, value);
|
||||||
};
|
};
|
||||||
this._syncController = new SyncController(schema, yBlock, doc, onChange);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import { ReactiveFlatYMap } from '../../reactive/flat-native-y.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, value: unknown) => void
|
||||||
|
) {
|
||||||
|
const { id, flavour, version, yChildren, props } = this._parseYBlock();
|
||||||
|
|
||||||
|
this.id = id;
|
||||||
|
this.flavour = flavour;
|
||||||
|
this.yChildren = yChildren;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
|
this.model = this._createModel(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>();
|
||||||
|
model.schema = schema;
|
||||||
|
|
||||||
|
model.id = this.id;
|
||||||
|
model.keys = Array.from(props).map(key => key.replace('prop:', ''));
|
||||||
|
model.yBlock = this.yBlock;
|
||||||
|
const reactive = new ReactiveFlatYMap(
|
||||||
|
this.yBlock,
|
||||||
|
model.deleted,
|
||||||
|
this.onChange
|
||||||
|
);
|
||||||
|
this._reactive = reactive;
|
||||||
|
const proxy = reactive.proxy;
|
||||||
|
model._props = proxy;
|
||||||
|
model.stash = this.stash;
|
||||||
|
model.pop = this.pop;
|
||||||
|
if (this.doc) {
|
||||||
|
model.doc = this.doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = schema.model.props?.(internalPrimitives);
|
||||||
|
if (defaultProps) {
|
||||||
|
Object.entries(defaultProps).forEach(([key, value]) => {
|
||||||
|
const keyWithProp = `prop:${key}`;
|
||||||
|
if (keyWithProp in proxy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
proxy[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
const keyWithProp = `prop:${key}`;
|
||||||
|
if (props.has(keyWithProp)) return;
|
||||||
|
props.add(keyWithProp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
flavour,
|
||||||
|
version,
|
||||||
|
props,
|
||||||
|
yChildren,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get stash() {
|
||||||
|
return this._reactive.stash;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pop() {
|
||||||
|
return this._reactive.pop;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export const BlockSchema = z.object({
|
|||||||
flavour: FlavourSchema,
|
flavour: FlavourSchema,
|
||||||
parent: ParentSchema,
|
parent: ParentSchema,
|
||||||
children: ContentSchema,
|
children: ContentSchema,
|
||||||
|
isFlatData: z.boolean().optional(),
|
||||||
props: z
|
props: z
|
||||||
.function()
|
.function()
|
||||||
.args(z.custom<InternalPrimitives>())
|
.args(z.custom<InternalPrimitives>())
|
||||||
@@ -71,6 +72,7 @@ export function defineBlockSchema<
|
|||||||
role: Role;
|
role: Role;
|
||||||
parent?: string[];
|
parent?: string[];
|
||||||
children?: string[];
|
children?: string[];
|
||||||
|
isFlatData?: boolean;
|
||||||
}>,
|
}>,
|
||||||
Model extends BlockModel<Props>,
|
Model extends BlockModel<Props>,
|
||||||
Transformer extends BaseBlockTransformer<Props>,
|
Transformer extends BaseBlockTransformer<Props>,
|
||||||
@@ -102,6 +104,7 @@ export function defineBlockSchema({
|
|||||||
role: RoleType;
|
role: RoleType;
|
||||||
parent?: string[];
|
parent?: string[];
|
||||||
children?: string[];
|
children?: string[];
|
||||||
|
isFlatData?: boolean;
|
||||||
};
|
};
|
||||||
props?: (internalPrimitives: InternalPrimitives) => Record<string, unknown>;
|
props?: (internalPrimitives: InternalPrimitives) => Record<string, unknown>;
|
||||||
toModel?: () => BlockModel;
|
toModel?: () => BlockModel;
|
||||||
@@ -116,6 +119,7 @@ export function defineBlockSchema({
|
|||||||
flavour,
|
flavour,
|
||||||
props,
|
props,
|
||||||
toModel,
|
toModel,
|
||||||
|
isFlatData: metadata.isFlatData,
|
||||||
},
|
},
|
||||||
transformer,
|
transformer,
|
||||||
} satisfies z.infer<typeof BlockSchema>;
|
} satisfies z.infer<typeof BlockSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Doc as YDoc, YEvent } from 'yjs';
|
||||||
|
import { UndoManager } from 'yjs';
|
||||||
|
|
||||||
|
import type { ProxyOptions } from './types';
|
||||||
|
|
||||||
|
export abstract class BaseReactiveYData<T, Y> {
|
||||||
|
protected _getOrigin = (
|
||||||
|
doc: YDoc
|
||||||
|
): {
|
||||||
|
doc: YDoc;
|
||||||
|
proxy: true;
|
||||||
|
|
||||||
|
target: BaseReactiveYData<any, any>;
|
||||||
|
} => {
|
||||||
|
return {
|
||||||
|
doc,
|
||||||
|
proxy: true,
|
||||||
|
target: this,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _onObserve = (event: YEvent<any>, handler: () => void) => {
|
||||||
|
if (
|
||||||
|
event.transaction.origin?.proxy !== true &&
|
||||||
|
(!event.transaction.local ||
|
||||||
|
event.transaction.origin instanceof UndoManager)
|
||||||
|
) {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._options?.onChange?.(this._proxy);
|
||||||
|
};
|
||||||
|
|
||||||
|
protected abstract readonly _options?: ProxyOptions<T>;
|
||||||
|
|
||||||
|
protected abstract readonly _proxy: T;
|
||||||
|
|
||||||
|
protected _skipNext = false;
|
||||||
|
|
||||||
|
protected abstract readonly _source: T;
|
||||||
|
|
||||||
|
protected readonly _stashed = new Set<string | number>();
|
||||||
|
|
||||||
|
protected _transact = (doc: YDoc, fn: () => void) => {
|
||||||
|
doc.transact(fn, this._getOrigin(doc));
|
||||||
|
};
|
||||||
|
|
||||||
|
protected _updateWithSkip = (fn: () => void) => {
|
||||||
|
if (this._skipNext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._skipNext = true;
|
||||||
|
fn();
|
||||||
|
this._skipNext = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected abstract readonly _ySource: Y;
|
||||||
|
|
||||||
|
get proxy() {
|
||||||
|
return this._proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract pop(prop: string | number): void;
|
||||||
|
abstract stash(prop: string | number): void;
|
||||||
|
}
|
||||||
372
blocksuite/framework/store/src/reactive/flat-native-y.ts
Normal file
372
blocksuite/framework/store/src/reactive/flat-native-y.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||||
|
import { type Slot } from '@blocksuite/global/utils';
|
||||||
|
import { signal } from '@preact/signals-core';
|
||||||
|
import {
|
||||||
|
Array as YArray,
|
||||||
|
Map as YMap,
|
||||||
|
Text as YText,
|
||||||
|
type YMapEvent,
|
||||||
|
} from 'yjs';
|
||||||
|
|
||||||
|
import { BaseReactiveYData } from './base-reactive-data';
|
||||||
|
import { Boxed, type OnBoxedChange } from './boxed';
|
||||||
|
import { isPureObject } from './is-pure-object';
|
||||||
|
import { native2Y, y2Native } from './native-y';
|
||||||
|
import { ReactiveYArray } from './proxy';
|
||||||
|
import { type OnTextChange, Text } from './text';
|
||||||
|
import type { ProxyOptions, UnRecord } from './types';
|
||||||
|
|
||||||
|
const keyWithoutPrefix = (key: string) => key.replace(/(prop|sys):/, '');
|
||||||
|
|
||||||
|
type OnChange = (key: string, value: unknown) => void;
|
||||||
|
type Transform = (key: string, value: unknown, origin: unknown) => unknown;
|
||||||
|
|
||||||
|
type CreateProxyOptions = {
|
||||||
|
basePath?: string;
|
||||||
|
onChange?: OnChange;
|
||||||
|
transform?: Transform;
|
||||||
|
onDispose: Slot;
|
||||||
|
shouldByPassSignal: () => boolean;
|
||||||
|
byPassSignalUpdate: (fn: () => void) => void;
|
||||||
|
stashed: Set<string | number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxySymbol = Symbol('proxy');
|
||||||
|
|
||||||
|
function isProxy(value: unknown): boolean {
|
||||||
|
return proxySymbol in Object.getPrototypeOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markProxy(value: UnRecord): UnRecord {
|
||||||
|
Object.setPrototypeOf(value, {
|
||||||
|
[proxySymbol]: true,
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProxy(
|
||||||
|
yMap: YMap<unknown>,
|
||||||
|
base: UnRecord,
|
||||||
|
root: UnRecord,
|
||||||
|
options: CreateProxyOptions
|
||||||
|
): UnRecord {
|
||||||
|
const {
|
||||||
|
onDispose,
|
||||||
|
shouldByPassSignal,
|
||||||
|
byPassSignalUpdate,
|
||||||
|
basePath,
|
||||||
|
onChange,
|
||||||
|
transform = (_key, value) => value,
|
||||||
|
stashed,
|
||||||
|
} = options;
|
||||||
|
const isRoot = !basePath;
|
||||||
|
|
||||||
|
if (isProxy(base)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(base).forEach(([key, value]) => {
|
||||||
|
if (isPureObject(value) && !isProxy(value)) {
|
||||||
|
const proxy = createProxy(yMap, value as UnRecord, root, {
|
||||||
|
...options,
|
||||||
|
basePath: `${basePath}.${key}`,
|
||||||
|
});
|
||||||
|
base[key] = proxy;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxy = new Proxy(base, {
|
||||||
|
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 list: Array<() => void> = [];
|
||||||
|
const fullPath = basePath ? `${basePath}.${p}` : p;
|
||||||
|
const firstKey = fullPath.split('.')[0];
|
||||||
|
if (!firstKey) {
|
||||||
|
throw new Error(`Invalid key for: ${fullPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStashed = stashed.has(firstKey);
|
||||||
|
|
||||||
|
const updateSignal = (value: unknown) => {
|
||||||
|
if (shouldByPassSignal()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signalKey = `${firstKey}$`;
|
||||||
|
if (!(signalKey in root)) {
|
||||||
|
if (!isRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signalData = signal(value);
|
||||||
|
root[signalKey] = signalData;
|
||||||
|
onDispose.once(
|
||||||
|
signalData.subscribe(next => {
|
||||||
|
byPassSignalUpdate(() => {
|
||||||
|
proxy[p] = next;
|
||||||
|
onChange?.(firstKey, next);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
onChange?.(firstKey, next);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPureObject(value)) {
|
||||||
|
const syncYMap = () => {
|
||||||
|
yMap.forEach((_, key) => {
|
||||||
|
if (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(() => {
|
||||||
|
yMap.set(`prop:${fullPath}`, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
run(value, fullPath);
|
||||||
|
if (list.length) {
|
||||||
|
yMap.doc?.transact(
|
||||||
|
() => {
|
||||||
|
list.forEach(fn => fn());
|
||||||
|
},
|
||||||
|
{ proxy: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isStashed) {
|
||||||
|
syncYMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = createProxy(yMap, value as UnRecord, root, {
|
||||||
|
...options,
|
||||||
|
basePath: fullPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = Reflect.set(target, p, next, receiver);
|
||||||
|
updateSignal(next);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yValue = native2Y(value);
|
||||||
|
const next = transform(firstKey, value, yValue);
|
||||||
|
if (!isStashed) {
|
||||||
|
yMap.doc?.transact(
|
||||||
|
() => {
|
||||||
|
yMap.set(`prop:${fullPath}`, yValue);
|
||||||
|
},
|
||||||
|
{ proxy: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Reflect.set(target, p, next, receiver);
|
||||||
|
updateSignal(next);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return Reflect.set(target, p, value, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
markProxy(proxy);
|
||||||
|
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReactiveFlatYMap extends BaseReactiveYData<
|
||||||
|
UnRecord,
|
||||||
|
YMap<unknown>
|
||||||
|
> {
|
||||||
|
protected readonly _proxy: UnRecord;
|
||||||
|
protected readonly _source: UnRecord;
|
||||||
|
protected readonly _options?: ProxyOptions<UnRecord>;
|
||||||
|
|
||||||
|
private readonly _observer = (event: YMapEvent<unknown>) => {
|
||||||
|
const yMap = this._ySource;
|
||||||
|
const proxy = this._proxy;
|
||||||
|
this._onObserve(event, () => {
|
||||||
|
event.keysChanged.forEach(key => {
|
||||||
|
const type = event.changes.keys.get(key);
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type.action === 'update' || type.action === 'add') {
|
||||||
|
const value = yMap.get(key);
|
||||||
|
const keyName: string = key.replace('prop:', '');
|
||||||
|
const keys = keyName.split('.');
|
||||||
|
const firstKey = keys[0];
|
||||||
|
if (this._stashed.has(firstKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void keys.reduce((acc, key, index, arr) => {
|
||||||
|
if (index === arr.length - 1) {
|
||||||
|
acc[key] = y2Native(value);
|
||||||
|
}
|
||||||
|
return acc[key] as UnRecord;
|
||||||
|
}, proxy as UnRecord);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type.action === 'delete') {
|
||||||
|
const keyName: string = key.replace('prop:', '');
|
||||||
|
const keys = keyName.split('.');
|
||||||
|
const firstKey = keys[0];
|
||||||
|
if (this._stashed.has(firstKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void keys.reduce((acc, key, index, arr) => {
|
||||||
|
if (index === arr.length - 1) {
|
||||||
|
delete acc[key];
|
||||||
|
}
|
||||||
|
return acc[key] as UnRecord;
|
||||||
|
}, proxy as UnRecord);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const value = this._proxy[key];
|
||||||
|
this._onChange?.(key, value);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _createDefaultData = (): UnRecord => {
|
||||||
|
const data: UnRecord = {};
|
||||||
|
const transform = this._transform;
|
||||||
|
Array.from(this._ySource.entries()).forEach(([key, value]) => {
|
||||||
|
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) {
|
||||||
|
finalData = transform(firstKey, new Text(value), value);
|
||||||
|
} else if (value instanceof YMap) {
|
||||||
|
throw new 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 = this._getProxy({} as UnRecord, path);
|
||||||
|
acc[key] = data;
|
||||||
|
}
|
||||||
|
if (index === allLength - 1) {
|
||||||
|
acc[key] = finalData;
|
||||||
|
}
|
||||||
|
return acc[key] as UnRecord;
|
||||||
|
}, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _getProxy = (source: UnRecord, path?: string): UnRecord => {
|
||||||
|
return createProxy(this._ySource, source, source, {
|
||||||
|
onDispose: this._onDispose,
|
||||||
|
shouldByPassSignal: () => this._skipNext,
|
||||||
|
byPassSignalUpdate: this._updateWithSkip,
|
||||||
|
basePath: path,
|
||||||
|
onChange: this._onChange,
|
||||||
|
transform: this._transform,
|
||||||
|
stashed: this._stashed,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected readonly _ySource: YMap<unknown>,
|
||||||
|
private readonly _onDispose: Slot,
|
||||||
|
private readonly _onChange?: OnChange
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
const source = this._createDefaultData();
|
||||||
|
this._source = source;
|
||||||
|
|
||||||
|
const proxy = this._getProxy(source);
|
||||||
|
|
||||||
|
Object.entries(source).forEach(([key, value]) => {
|
||||||
|
const signalData = signal(value);
|
||||||
|
source[`${key}$`] = signalData;
|
||||||
|
_onDispose.once(
|
||||||
|
signalData.subscribe(next => {
|
||||||
|
this._updateWithSkip(() => {
|
||||||
|
proxy[key] = next;
|
||||||
|
this._onChange?.(key, next);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._proxy = proxy;
|
||||||
|
this._ySource.observe(this._observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
export * from './boxed.js';
|
export * from './boxed.js';
|
||||||
|
export * from './flat-native-y.js';
|
||||||
|
export * from './is-pure-object.js';
|
||||||
|
export * from './native-y.js';
|
||||||
export * from './proxy.js';
|
export * from './proxy.js';
|
||||||
|
export * from './stash-pop.js';
|
||||||
export * from './text.js';
|
export * from './text.js';
|
||||||
export * from './utils.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.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);
|
||||||
|
}
|
||||||
@@ -2,16 +2,12 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
|||||||
import type { YArrayEvent, YMapEvent } from 'yjs';
|
import type { YArrayEvent, YMapEvent } from 'yjs';
|
||||||
import { Array as YArray, Map as YMap } 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 { Boxed, type OnBoxedChange } from './boxed.js';
|
||||||
|
import { proxies } from './memory.js';
|
||||||
|
import { native2Y, y2Native } from './native-y.js';
|
||||||
import { type OnTextChange, Text } from './text.js';
|
import { type OnTextChange, Text } from './text.js';
|
||||||
import type { UnRecord } from './utils.js';
|
import type { ProxyOptions, TransformOptions, UnRecord } from './types.js';
|
||||||
import { BaseReactiveYData, native2Y, y2Native } from './utils.js';
|
|
||||||
|
|
||||||
export type ProxyOptions<T> = {
|
|
||||||
onChange?: (data: T) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const proxies = new WeakMap<any, BaseReactiveYData<any, any>>();
|
|
||||||
|
|
||||||
export class ReactiveYArray extends BaseReactiveYData<
|
export class ReactiveYArray extends BaseReactiveYData<
|
||||||
unknown[],
|
unknown[],
|
||||||
@@ -298,60 +294,34 @@ export function createYProxy<Data>(
|
|||||||
return proxies.get(yAbstract)!.proxy as Data;
|
return proxies.get(yAbstract)!.proxy as Data;
|
||||||
}
|
}
|
||||||
|
|
||||||
return y2Native(yAbstract, {
|
const transform: TransformOptions['transform'] = (value, origin) => {
|
||||||
transform: (value, origin) => {
|
if (value instanceof Text) {
|
||||||
if (value instanceof Text) {
|
value.bind(options.onChange as OnTextChange);
|
||||||
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 value;
|
||||||
},
|
}
|
||||||
}) as Data;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export function stashProp(yMap: YMap<unknown>, prop: string): void;
|
return value;
|
||||||
export function stashProp(yMap: YArray<unknown>, prop: number): void;
|
};
|
||||||
export function stashProp(yAbstract: unknown, prop: string | number) {
|
|
||||||
const proxy = proxies.get(yAbstract);
|
|
||||||
if (!proxy) {
|
|
||||||
throw new BlockSuiteError(
|
|
||||||
ErrorCode.ReactiveProxyError,
|
|
||||||
'YData is not subscribed before changes'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
proxy.stash(prop);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function popProp(yMap: YMap<unknown>, prop: string): void;
|
return y2Native(yAbstract, { transform }) as Data;
|
||||||
export function popProp(yMap: YArray<unknown>, prop: number): void;
|
|
||||||
export function popProp(yAbstract: unknown, prop: string | number) {
|
|
||||||
const proxy = proxies.get(yAbstract);
|
|
||||||
if (!proxy) {
|
|
||||||
throw new BlockSuiteError(
|
|
||||||
ErrorCode.ReactiveProxyError,
|
|
||||||
'YData is not subscribed before changes'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
proxy.pop(prop);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
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) => void;
|
||||||
|
};
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import type { Doc as YDoc, YEvent } from 'yjs';
|
|
||||||
import { Array as YArray, Map as YMap, Text as YText, UndoManager } from 'yjs';
|
|
||||||
|
|
||||||
import { Boxed } from './boxed.js';
|
|
||||||
import type { ProxyOptions } from './proxy.js';
|
|
||||||
import { Text } from './text.js';
|
|
||||||
|
|
||||||
export type Native2Y<T> =
|
|
||||||
T extends Record<string, infer U>
|
|
||||||
? YMap<U>
|
|
||||||
: T extends Array<infer U>
|
|
||||||
? YArray<U>
|
|
||||||
: T;
|
|
||||||
|
|
||||||
export function isPureObject(value: unknown): value is object {
|
|
||||||
return (
|
|
||||||
value !== null &&
|
|
||||||
typeof value === 'object' &&
|
|
||||||
Object.prototype.toString.call(value) === '[object Object]' &&
|
|
||||||
[Object, undefined, null].some(x => x === value.constructor)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TransformOptions = {
|
|
||||||
deep?: boolean;
|
|
||||||
transform?: (value: unknown, origin: unknown) => unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function native2Y<T>(
|
|
||||||
value: T,
|
|
||||||
{ deep = true, transform = x => x }: TransformOptions = {}
|
|
||||||
): Native2Y<T> {
|
|
||||||
if (value instanceof Boxed) {
|
|
||||||
return value.yMap as Native2Y<T>;
|
|
||||||
}
|
|
||||||
if (value instanceof Text) {
|
|
||||||
if (value.yText.doc) {
|
|
||||||
return value.yText.clone() as Native2Y<T>;
|
|
||||||
}
|
|
||||||
return value.yText as Native2Y<T>;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const yArray: YArray<unknown> = new YArray<unknown>();
|
|
||||||
const result = value.map(item => {
|
|
||||||
return deep ? native2Y(item, { deep, transform }) : item;
|
|
||||||
});
|
|
||||||
yArray.insert(0, result);
|
|
||||||
|
|
||||||
return yArray as Native2Y<T>;
|
|
||||||
}
|
|
||||||
if (isPureObject(value)) {
|
|
||||||
const yMap = new YMap<unknown>();
|
|
||||||
Object.entries(value).forEach(([key, value]) => {
|
|
||||||
yMap.set(key, deep ? native2Y(value, { deep, transform }) : value);
|
|
||||||
});
|
|
||||||
|
|
||||||
return yMap as Native2Y<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value as Native2Y<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function y2Native(
|
|
||||||
yAbstract: unknown,
|
|
||||||
{ deep = true, transform = x => x }: TransformOptions = {}
|
|
||||||
) {
|
|
||||||
if (Boxed.is(yAbstract)) {
|
|
||||||
const data = new Boxed(yAbstract);
|
|
||||||
return transform(data, yAbstract);
|
|
||||||
}
|
|
||||||
if (yAbstract instanceof YText) {
|
|
||||||
const data = new Text(yAbstract);
|
|
||||||
return transform(data, yAbstract);
|
|
||||||
}
|
|
||||||
if (yAbstract instanceof YArray) {
|
|
||||||
const data: unknown[] = yAbstract
|
|
||||||
.toArray()
|
|
||||||
.map(item => (deep ? y2Native(item, { deep, transform }) : item));
|
|
||||||
|
|
||||||
return transform(data, yAbstract);
|
|
||||||
}
|
|
||||||
if (yAbstract instanceof YMap) {
|
|
||||||
const data: Record<string, unknown> = Object.fromEntries(
|
|
||||||
Array.from(yAbstract.entries()).map(([key, value]) => {
|
|
||||||
return [key, deep ? y2Native(value, { deep, transform }) : value] as [
|
|
||||||
string,
|
|
||||||
unknown,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return transform(data, yAbstract);
|
|
||||||
}
|
|
||||||
|
|
||||||
return transform(yAbstract, yAbstract);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UnRecord = Record<string, unknown>;
|
|
||||||
|
|
||||||
export abstract class BaseReactiveYData<T, Y> {
|
|
||||||
protected _getOrigin = (
|
|
||||||
doc: YDoc
|
|
||||||
): {
|
|
||||||
doc: YDoc;
|
|
||||||
proxy: true;
|
|
||||||
|
|
||||||
target: BaseReactiveYData<any, any>;
|
|
||||||
} => {
|
|
||||||
return {
|
|
||||||
doc,
|
|
||||||
proxy: true,
|
|
||||||
target: this,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
protected _onObserve = (event: YEvent<any>, handler: () => void) => {
|
|
||||||
if (
|
|
||||||
event.transaction.origin?.proxy !== true &&
|
|
||||||
(!event.transaction.local ||
|
|
||||||
event.transaction.origin instanceof UndoManager)
|
|
||||||
) {
|
|
||||||
handler();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._options.onChange?.(this._proxy);
|
|
||||||
};
|
|
||||||
|
|
||||||
protected abstract readonly _options: ProxyOptions<T>;
|
|
||||||
|
|
||||||
protected abstract readonly _proxy: T;
|
|
||||||
|
|
||||||
protected _skipNext = false;
|
|
||||||
|
|
||||||
protected abstract readonly _source: T;
|
|
||||||
|
|
||||||
protected readonly _stashed = new Set<string | number>();
|
|
||||||
|
|
||||||
protected _transact = (doc: YDoc, fn: () => void) => {
|
|
||||||
doc.transact(fn, this._getOrigin(doc));
|
|
||||||
};
|
|
||||||
|
|
||||||
protected _updateWithSkip = (fn: () => void) => {
|
|
||||||
this._skipNext = true;
|
|
||||||
fn();
|
|
||||||
this._skipNext = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
protected abstract readonly _ySource: Y;
|
|
||||||
|
|
||||||
get proxy() {
|
|
||||||
return this._proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract _getProxy(): T;
|
|
||||||
|
|
||||||
abstract pop(prop: string | number): void;
|
|
||||||
abstract stash(prop: string | number): void;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NATIVE_UNIQ_IDENTIFIER, TEXT_UNIQ_IDENTIFIER } from '../consts';
|
import { NATIVE_UNIQ_IDENTIFIER, TEXT_UNIQ_IDENTIFIER } from '../consts';
|
||||||
|
import { isPureObject } from '../reactive';
|
||||||
import { Boxed } from '../reactive/boxed';
|
import { Boxed } from '../reactive/boxed';
|
||||||
import { isPureObject } from '../reactive/index';
|
|
||||||
import { Text } from '../reactive/text';
|
import { Text } from '../reactive/text';
|
||||||
|
|
||||||
export function toJSON(value: unknown): unknown {
|
export function toJSON(value: unknown): unknown {
|
||||||
|
|||||||
Reference in New Issue
Block a user