diff --git a/blocksuite/framework/store/src/__tests__/block.unit.spec.ts b/blocksuite/framework/store/src/__tests__/block.unit.spec.ts index 75896e60b..db8c86206 100644 --- a/blocksuite/framework/store/src/__tests__/block.unit.spec.ts +++ b/blocksuite/framework/store/src/__tests__/block.unit.spec.ts @@ -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, + rows: {} as Record, + labels: [] as Array, + }), + metadata: { + role: 'content', + version: 1, + isFlatData: true, + }, +}); type RootModel = SchemaToModel; type TableModel = SchemaToModel; +type FlatTableModel = SchemaToModel; 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; + 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); + }); +}); diff --git a/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts b/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts index e29fd90f5..bdee1482a 100644 --- a/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts +++ b/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts @@ -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>(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).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>).get(0)).toBeInstanceOf( - Y.Map - ); - expect( - (map.get('arr') as Y.Array>).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; - native2: Boxed; - }; - }>(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>(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>(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>(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>(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).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>).get(0)).toBeInstanceOf( + Y.Map + ); + expect( + (map.get('arr') as Y.Array>).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; + native2: Boxed; + }; + }>(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>(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>(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>(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; + 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); +}); diff --git a/blocksuite/framework/store/src/model/block/block-model.ts b/blocksuite/framework/store/src/model/block/block-model.ts index 1feb39c5d..16ff97f2d 100644 --- a/blocksuite/framework/store/src/model/block/block-model.ts +++ b/blocksuite/framework/store/src/model/block/block-model.ts @@ -87,6 +87,15 @@ export class BlockModel< yBlock!: YBlock; + _props!: SignaledProps; + + 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; } diff --git a/blocksuite/framework/store/src/model/block/block.ts b/blocksuite/framework/store/src/model/block/block.ts index 7f9deed2a..d540289d7 100644 --- a/blocksuite/framework/store/src/model/block/block.ts +++ b/blocksuite/framework/store/src/model/block/block.ts @@ -1,12 +1,13 @@ 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; + private readonly _syncController: SyncController | FlatSyncController; blockViewType: BlockViewType = 'display'; @@ -45,6 +46,17 @@ export class Block { : (key: string, value: unknown) => { 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); + } } } diff --git a/blocksuite/framework/store/src/model/block/flat-sync-controller.ts b/blocksuite/framework/store/src/model/block/flat-sync-controller.ts new file mode 100644 index 000000000..30ed74593 --- /dev/null +++ b/blocksuite/framework/store/src/model/block/flat-sync-controller.ts @@ -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; + + 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) { + 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(); + 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 | undefined; + const props: Set = 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; + } +} diff --git a/blocksuite/framework/store/src/model/block/zod.ts b/blocksuite/framework/store/src/model/block/zod.ts index 7e5e5b64f..67666cd94 100644 --- a/blocksuite/framework/store/src/model/block/zod.ts +++ b/blocksuite/framework/store/src/model/block/zod.ts @@ -30,6 +30,7 @@ export const BlockSchema = z.object({ flavour: FlavourSchema, parent: ParentSchema, children: ContentSchema, + isFlatData: z.boolean().optional(), props: z .function() .args(z.custom()) @@ -71,6 +72,7 @@ export function defineBlockSchema< role: Role; parent?: string[]; children?: string[]; + isFlatData?: boolean; }>, Model extends BlockModel, Transformer extends BaseBlockTransformer, @@ -102,6 +104,7 @@ export function defineBlockSchema({ role: RoleType; parent?: string[]; children?: string[]; + isFlatData?: boolean; }; props?: (internalPrimitives: InternalPrimitives) => Record; toModel?: () => BlockModel; @@ -116,6 +119,7 @@ export function defineBlockSchema({ flavour, props, toModel, + isFlatData: metadata.isFlatData, }, transformer, } satisfies z.infer; diff --git a/blocksuite/framework/store/src/reactive/base-reactive-data.ts b/blocksuite/framework/store/src/reactive/base-reactive-data.ts new file mode 100644 index 000000000..fb829ccbd --- /dev/null +++ b/blocksuite/framework/store/src/reactive/base-reactive-data.ts @@ -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 { + protected _getOrigin = ( + doc: YDoc + ): { + doc: YDoc; + proxy: true; + + target: BaseReactiveYData; + } => { + return { + doc, + proxy: true, + target: this, + }; + }; + + protected _onObserve = (event: YEvent, 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; + + protected abstract readonly _proxy: T; + + protected _skipNext = false; + + protected abstract readonly _source: T; + + protected readonly _stashed = new Set(); + + 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; +} diff --git a/blocksuite/framework/store/src/reactive/flat-native-y.ts b/blocksuite/framework/store/src/reactive/flat-native-y.ts new file mode 100644 index 000000000..2d29c0376 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/flat-native-y.ts @@ -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; +}; + +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, + 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 +> { + protected readonly _proxy: UnRecord; + protected readonly _source: UnRecord; + protected readonly _options?: ProxyOptions; + + private readonly _observer = (event: YMapEvent) => { + 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, + 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); + }; +} diff --git a/blocksuite/framework/store/src/reactive/index.ts b/blocksuite/framework/store/src/reactive/index.ts index 5e528efc2..51d4a7b90 100644 --- a/blocksuite/framework/store/src/reactive/index.ts +++ b/blocksuite/framework/store/src/reactive/index.ts @@ -1,4 +1,8 @@ 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 './stash-pop.js'; export * from './text.js'; -export * from './utils.js'; +export * from './types.js'; diff --git a/blocksuite/framework/store/src/reactive/is-pure-object.ts b/blocksuite/framework/store/src/reactive/is-pure-object.ts new file mode 100644 index 000000000..6fa24f4de --- /dev/null +++ b/blocksuite/framework/store/src/reactive/is-pure-object.ts @@ -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) + ); +} diff --git a/blocksuite/framework/store/src/reactive/memory.ts b/blocksuite/framework/store/src/reactive/memory.ts new file mode 100644 index 000000000..2e64e3ae7 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/memory.ts @@ -0,0 +1,4 @@ +import type { BaseReactiveYData } from './base-reactive-data'; + +export const proxies = new WeakMap>(); +export const flatProxies = new WeakMap>(); diff --git a/blocksuite/framework/store/src/reactive/native-y.ts b/blocksuite/framework/store/src/reactive/native-y.ts new file mode 100644 index 000000000..89c9d6d51 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/native-y.ts @@ -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( + value: T, + { deep = true, transform = x => x }: TransformOptions = {} +): Native2Y { + if (value instanceof Boxed) { + return transform(value.yMap, value) as Native2Y; + } + if (value instanceof Text) { + if (value.yText.doc) { + return transform(value.yText.clone(), value) as Native2Y; + } + return transform(value.yText, value) as Native2Y; + } + if (Array.isArray(value)) { + const yArray: YArray = new YArray(); + const result = value.map(item => { + return deep ? native2Y(item, { deep, transform }) : item; + }); + yArray.insert(0, result); + + return transform(yArray, value) as Native2Y; + } + if (isPureObject(value)) { + const yMap = new YMap(); + Object.entries(value).forEach(([key, value]) => { + yMap.set(key, deep ? native2Y(value, { deep, transform }) : value); + }); + + return transform(yMap, value) as Native2Y; + } + + return transform(value, value) as Native2Y; +} + +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 = 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); +} diff --git a/blocksuite/framework/store/src/reactive/proxy.ts b/blocksuite/framework/store/src/reactive/proxy.ts index 3bc9d6c63..53ddcebd9 100644 --- a/blocksuite/framework/store/src/reactive/proxy.ts +++ b/blocksuite/framework/store/src/reactive/proxy.ts @@ -2,16 +2,12 @@ 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.js'; -import type { UnRecord } from './utils.js'; -import { BaseReactiveYData, native2Y, y2Native } from './utils.js'; - -export type ProxyOptions = { - onChange?: (data: T) => void; -}; - -const proxies = new WeakMap>(); +import type { ProxyOptions, TransformOptions, UnRecord } from './types.js'; export class ReactiveYArray extends BaseReactiveYData< unknown[], @@ -298,60 +294,34 @@ export function createYProxy( return proxies.get(yAbstract)!.proxy as Data; } - return y2Native(yAbstract, { - transform: (value, origin) => { - if (value instanceof Text) { - value.bind(options.onChange as OnTextChange); - return value; - } - if (Boxed.is(origin)) { - (value as Boxed).bind(options.onChange as OnBoxedChange); - return value; - } - if (origin instanceof YArray) { - const data = new ReactiveYArray( - value as unknown[], - origin, - options as ProxyOptions - ); - return data.proxy; - } - if (origin instanceof YMap) { - const data = new ReactiveYMap( - value as UnRecord, - origin, - options as ProxyOptions - ); - return data.proxy; - } - + const transform: TransformOptions['transform'] = (value, origin) => { + if (value instanceof Text) { + value.bind(options.onChange as OnTextChange); 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 + ); + return data.proxy; + } + if (origin instanceof YMap) { + const data = new ReactiveYMap( + value as UnRecord, + origin, + options as ProxyOptions + ); + return data.proxy; + } -export function stashProp(yMap: YMap, prop: string): void; -export function stashProp(yMap: YArray, 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); -} + return value; + }; -export function popProp(yMap: YMap, prop: string): void; -export function popProp(yMap: YArray, 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); + return y2Native(yAbstract, { transform }) as Data; } diff --git a/blocksuite/framework/store/src/reactive/stash-pop.ts b/blocksuite/framework/store/src/reactive/stash-pop.ts new file mode 100644 index 000000000..905cf3038 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/stash-pop.ts @@ -0,0 +1,17 @@ +import type { Array as YArray, Map as YMap } from 'yjs'; + +import { proxies } from './memory'; + +export function stashProp(yMap: YMap, prop: string): void; +export function stashProp(yMap: YArray, prop: number): void; +export function stashProp(yAbstract: unknown, prop: string | number) { + const proxy = proxies.get(yAbstract); + proxy?.stash(prop); +} + +export function popProp(yMap: YMap, prop: string): void; +export function popProp(yMap: YArray, prop: number): void; +export function popProp(yAbstract: unknown, prop: string | number) { + const proxy = proxies.get(yAbstract); + proxy?.pop(prop); +} diff --git a/blocksuite/framework/store/src/reactive/types.ts b/blocksuite/framework/store/src/reactive/types.ts new file mode 100644 index 000000000..8aa819b74 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/types.ts @@ -0,0 +1,19 @@ +import type { Array as YArray, Map as YMap } from 'yjs'; + +export type UnRecord = Record; + +export type Native2Y = + T extends Record + ? YMap + : T extends Array + ? YArray + : T; + +export type TransformOptions = { + deep?: boolean; + transform?: (value: unknown, origin: unknown) => unknown; +}; + +export type ProxyOptions = { + onChange?: (data: T) => void; +}; diff --git a/blocksuite/framework/store/src/reactive/utils.ts b/blocksuite/framework/store/src/reactive/utils.ts deleted file mode 100644 index d186a4dd0..000000000 --- a/blocksuite/framework/store/src/reactive/utils.ts +++ /dev/null @@ -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 extends Record - ? YMap - : T extends Array - ? YArray - : 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( - value: T, - { deep = true, transform = x => x }: TransformOptions = {} -): Native2Y { - if (value instanceof Boxed) { - return value.yMap as Native2Y; - } - if (value instanceof Text) { - if (value.yText.doc) { - return value.yText.clone() as Native2Y; - } - return value.yText as Native2Y; - } - if (Array.isArray(value)) { - const yArray: YArray = new YArray(); - const result = value.map(item => { - return deep ? native2Y(item, { deep, transform }) : item; - }); - yArray.insert(0, result); - - return yArray as Native2Y; - } - if (isPureObject(value)) { - const yMap = new YMap(); - Object.entries(value).forEach(([key, value]) => { - yMap.set(key, deep ? native2Y(value, { deep, transform }) : value); - }); - - return yMap as Native2Y; - } - - return value as Native2Y; -} - -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 = 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; - -export abstract class BaseReactiveYData { - protected _getOrigin = ( - doc: YDoc - ): { - doc: YDoc; - proxy: true; - - target: BaseReactiveYData; - } => { - return { - doc, - proxy: true, - target: this, - }; - }; - - protected _onObserve = (event: YEvent, 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; - - protected abstract readonly _proxy: T; - - protected _skipNext = false; - - protected abstract readonly _source: T; - - protected readonly _stashed = new Set(); - - 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; -} diff --git a/blocksuite/framework/store/src/transformer/json.ts b/blocksuite/framework/store/src/transformer/json.ts index d7aa0f278..5dc0efab7 100644 --- a/blocksuite/framework/store/src/transformer/json.ts +++ b/blocksuite/framework/store/src/transformer/json.ts @@ -1,6 +1,6 @@ import { NATIVE_UNIQ_IDENTIFIER, TEXT_UNIQ_IDENTIFIER } from '../consts'; +import { isPureObject } from '../reactive'; import { Boxed } from '../reactive/boxed'; -import { isPureObject } from '../reactive/index'; import { Text } from '../reactive/text'; export function toJSON(value: unknown): unknown {