diff --git a/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts b/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts index ce66c8ce6a..db6d9b46d9 100644 --- a/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts +++ b/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts @@ -2,7 +2,7 @@ import { Subject } from 'rxjs'; import { describe, expect, test } from 'vitest'; import * as Y from 'yjs'; -import { ReactiveFlatYMap } from '../reactive/flat-native-y.js'; +import { ReactiveFlatYMap } from '../reactive/flat-native-y/index.js'; import type { Text } from '../reactive/index.js'; import { Boxed, createYProxy, popProp, stashProp } from '../reactive/index.js'; diff --git a/blocksuite/framework/store/src/model/block/flat-sync-controller.ts b/blocksuite/framework/store/src/model/block/flat-sync-controller.ts index 4732c78151..97ad52efdf 100644 --- a/blocksuite/framework/store/src/model/block/flat-sync-controller.ts +++ b/blocksuite/framework/store/src/model/block/flat-sync-controller.ts @@ -1,7 +1,7 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import * as Y from 'yjs'; -import { ReactiveFlatYMap } from '../../reactive/flat-native-y.js'; +import { ReactiveFlatYMap } from '../../reactive/flat-native-y/index.js'; import type { Schema } from '../../schema/schema.js'; import type { Store } from '../store/store.js'; import { BlockModel } from './block-model.js'; diff --git a/blocksuite/framework/store/src/reactive/flat-native-y.ts b/blocksuite/framework/store/src/reactive/flat-native-y.ts deleted file mode 100644 index 1d517cf58e..0000000000 --- a/blocksuite/framework/store/src/reactive/flat-native-y.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; -import { signal } from '@preact/signals-core'; -import type { Subject } from 'rxjs'; -import { - Array as YArray, - Map as YMap, - Text as YText, - type YMapEvent, -} from 'yjs'; - -import { SYS_KEYS } from '../consts'; -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):/, ''); - -const keyWithPrefix = (key: string) => - SYS_KEYS.has(key) ? `sys:${key}` : `prop:${key}`; - -type OnChange = (key: string, isLocal: boolean) => void; -type Transform = (key: string, value: unknown, origin: unknown) => unknown; - -type CreateProxyOptions = { - basePath?: string; - onChange?: OnChange; - transform: Transform; - onDispose: Subject; - shouldByPassSignal: () => boolean; - shouldByPassYjs: () => boolean; - byPassSignalUpdate: (fn: () => void) => void; - stashed: Set; - initialized: () => boolean; -}; - -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, - shouldByPassYjs, - byPassSignalUpdate, - basePath, - onChange, - initialized, - transform, - 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 ? `${basePath}.${key}` : 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; - const unsubscribe = signalData.subscribe(next => { - if (!initialized()) { - return; - } - byPassSignalUpdate(() => { - proxy[p] = next; - onChange?.(firstKey, true); - }); - }); - const subscription = onDispose.subscribe(() => { - subscription.unsubscribe(); - unsubscribe(); - }); - 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, true); - }); - }; - - if (isPureObject(value)) { - const syncYMap = () => { - if (shouldByPassYjs()) { - return; - } - yMap.forEach((_, key) => { - if (initialized() && keyWithoutPrefix(key).startsWith(fullPath)) { - yMap.delete(key); - } - }); - const run = (obj: object, basePath: string) => { - Object.entries(obj).forEach(([key, value]) => { - const fullPath = basePath ? `${basePath}.${key}` : key; - if (isPureObject(value)) { - run(value, fullPath); - } else { - list.push(() => { - if (value instanceof Text || Boxed.is(value)) { - value.bind(() => { - onChange?.(firstKey, true); - }); - } - yMap.set(keyWithPrefix(fullPath), native2Y(value)); - }); - } - }); - }; - run(value, fullPath); - if (list.length && initialized()) { - 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; - } - - if (value instanceof Text || Boxed.is(value)) { - value.bind(() => { - onChange?.(firstKey, true); - }); - } - const yValue = native2Y(value); - const next = transform(firstKey, value, yValue); - if (!isStashed && initialized() && !shouldByPassYjs()) { - yMap.doc?.transact( - () => { - yMap.set(keyWithPrefix(fullPath), yValue); - }, - { proxy: true } - ); - } - - const result = Reflect.set(target, p, next, receiver); - updateSignal(next); - return result; - } - return Reflect.set(target, p, value, receiver); - }, - deleteProperty: (target, p) => { - if (typeof p === 'string') { - 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 = () => { - if (shouldByPassSignal()) { - return; - } - - const signalKey = `${firstKey}$`; - if (!(signalKey in root)) { - if (!isRoot) { - return; - } - delete root[signalKey]; - return; - } - byPassSignalUpdate(() => { - const prev = root[firstKey]; - const next = isRoot - ? prev - : isPureObject(prev) - ? { ...prev } - : Array.isArray(prev) - ? [...prev] - : prev; - // @ts-expect-error allow magic props - root[signalKey].value = next; - onChange?.(firstKey, true); - }); - }; - - if (!isStashed && initialized() && !shouldByPassYjs()) { - yMap.doc?.transact( - () => { - const fullKey = keyWithPrefix(fullPath); - yMap.forEach((_, key) => { - if (key.startsWith(fullKey)) { - yMap.delete(key); - } - }); - }, - { proxy: true } - ); - } - - const result = Reflect.deleteProperty(target, p); - updateSignal(); - return result; - } - return Reflect.deleteProperty(target, p); - }, - }); - - 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 _initialized; - - 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 = keyWithoutPrefix(key); - const keys = keyName.split('.'); - const firstKey = keys[0]; - if (this._stashed.has(firstKey)) { - return; - } - this._updateWithYjsSkip(() => { - void keys.reduce((acc, key, index, arr) => { - if (!acc[key] && index !== arr.length - 1) { - acc[key] = {}; - } - if (index === arr.length - 1) { - acc[key] = y2Native(value, { - transform: (value, origin) => { - return this._transform(firstKey, value, origin); - }, - }); - } - return acc[key] as UnRecord; - }, proxy as UnRecord); - }); - this._onChange?.(firstKey, false); - return; - } - if (type.action === 'delete') { - const keyName: string = keyWithoutPrefix(key); - const keys = keyName.split('.'); - const firstKey = keys[0]; - if (this._stashed.has(firstKey)) { - return; - } - this._updateWithYjsSkip(() => { - void keys.reduce((acc, key, index) => { - if (index === keys.length - 1) { - delete acc[key]; - let curr = acc; - let parentKey = keys[index - 1]; - let parent = proxy as UnRecord; - let path = keys.slice(0, -2); - - for (let i = keys.length - 2; i > 0; i--) { - for (const pathKey of path) { - parent = parent[pathKey] as UnRecord; - } - if (!isEmptyObject(curr)) { - break; - } - deleteEmptyObject(curr, parentKey, parent); - curr = parent; - parentKey = keys[i - 1]; - path = path.slice(0, -1); - parent = proxy as UnRecord; - } - } - return acc[key] as UnRecord; - }, proxy as UnRecord); - }); - 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 (_: unknown, isLocal: boolean) => { - this._onChange?.(key, isLocal); - }; - }; - - private readonly _createDefaultData = (): UnRecord => { - const root: UnRecord = {}; - const transform = this._transform; - Array.from(this._ySource.entries()).forEach(([key, value]) => { - if (key.startsWith('sys')) { - return; - } - const keys = keyWithoutPrefix(key).split('.'); - const firstKey = keys[0]; - - let finalData = value; - if (Boxed.is(value)) { - finalData = transform(firstKey, new Boxed(value), value); - } else if (value instanceof YArray) { - finalData = transform(firstKey, value.toArray(), value); - } else if (value instanceof YText) { - const next = new Text(value); - finalData = transform(firstKey, next, value); - } else if (value instanceof YMap) { - throw new BlockSuiteError( - 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, root, path); - acc[key] = data; - } - if (index === allLength - 1) { - acc[key] = finalData; - } - return acc[key] as UnRecord; - }, root); - }); - - return root; - }; - - private _byPassYjs = false; - - private readonly _getProxy = ( - source: UnRecord, - root: UnRecord, - path?: string - ): UnRecord => { - return createProxy(this._ySource, source, root, { - onDispose: this._onDispose, - shouldByPassSignal: () => this._skipNext, - byPassSignalUpdate: this._updateWithSkip, - shouldByPassYjs: () => this._byPassYjs, - basePath: path, - onChange: this._onChange, - transform: this._transform, - stashed: this._stashed, - initialized: () => this._initialized, - }); - }; - - private readonly _updateWithYjsSkip = (fn: () => void) => { - this._byPassYjs = true; - fn(); - this._byPassYjs = false; - }; - - constructor( - protected readonly _ySource: YMap, - private readonly _onDispose: Subject, - private readonly _onChange?: OnChange - ) { - super(); - this._initialized = false; - const source = this._createDefaultData(); - this._source = source; - - const proxy = this._getProxy(source, source); - - Object.entries(source).forEach(([key, value]) => { - const signalData = signal(value); - source[`${key}$`] = signalData; - const unsubscribe = signalData.subscribe(next => { - if (!this._initialized) { - return; - } - this._updateWithSkip(() => { - proxy[key] = next; - this._onChange?.(key, true); - }); - }); - const subscription = _onDispose.subscribe(() => { - subscription.unsubscribe(); - unsubscribe(); - }); - }); - - this._proxy = proxy; - this._ySource.observe(this._observer); - this._initialized = true; - } - - 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); - }; -} - -function isEmptyObject(obj: UnRecord): boolean { - return Object.keys(obj).length === 0; -} - -function deleteEmptyObject(obj: UnRecord, key: string, parent: UnRecord): void { - if (isEmptyObject(obj)) { - delete parent[key]; - } -} diff --git a/blocksuite/framework/store/src/reactive/flat-native-y/index.ts b/blocksuite/framework/store/src/reactive/flat-native-y/index.ts new file mode 100644 index 0000000000..d6c516d1a9 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/flat-native-y/index.ts @@ -0,0 +1,256 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { signal } from '@preact/signals-core'; +import type { Subject } from 'rxjs'; +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 { y2Native } from '../native-y'; +import { ReactiveYArray } from '../proxy'; +import { type OnTextChange, Text } from '../text'; +import type { ProxyOptions, UnRecord } from '../types'; +import { createProxy } from './proxy'; +import type { OnChange } from './types'; +import { + deleteEmptyObject, + getFirstKey, + isEmptyObject, + keyWithoutPrefix, +} from './utils'; + +export class ReactiveFlatYMap extends BaseReactiveYData< + UnRecord, + YMap +> { + protected readonly _proxy: UnRecord; + protected readonly _source: UnRecord; + protected readonly _options?: ProxyOptions; + + private readonly _initialized; + + 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 = keyWithoutPrefix(key); + const firstKey = getFirstKey(keyName); + if (this._stashed.has(firstKey)) { + return; + } + this._updateWithYjsSkip(() => { + const keys = keyName.split('.'); + void keys.reduce((acc, key, index, arr) => { + if (!acc[key] && index !== arr.length - 1) { + acc[key] = {}; + } + if (index === arr.length - 1) { + acc[key] = y2Native(value, { + transform: (value, origin) => { + return this._transform(firstKey, value, origin); + }, + }); + } + return acc[key] as UnRecord; + }, proxy as UnRecord); + }); + this._onChange?.(firstKey, false); + return; + } + if (type.action === 'delete') { + const keyName: string = keyWithoutPrefix(key); + const firstKey = getFirstKey(keyName); + if (this._stashed.has(firstKey)) { + return; + } + this._updateWithYjsSkip(() => { + const keys = keyName.split('.'); + void keys.reduce((acc, key, index) => { + if (index === keys.length - 1) { + delete acc[key]; + let curr = acc; + let parentKey = keys[index - 1]; + let parent = proxy as UnRecord; + let path = keys.slice(0, -2); + + for (let i = keys.length - 2; i > 0; i--) { + for (const pathKey of path) { + parent = parent[pathKey] as UnRecord; + } + if (!isEmptyObject(curr)) { + break; + } + deleteEmptyObject(curr, parentKey, parent); + curr = parent; + parentKey = keys[i - 1]; + path = path.slice(0, -1); + parent = proxy as UnRecord; + } + } + return acc[key] as UnRecord; + }, proxy as UnRecord); + }); + 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 (_: unknown, isLocal: boolean) => { + this._onChange?.(key, isLocal); + }; + }; + + private readonly _createDefaultData = (): UnRecord => { + const root: UnRecord = {}; + const transform = this._transform; + Array.from(this._ySource.entries()).forEach(([key, value]) => { + if (key.startsWith('sys')) { + return; + } + const keys = keyWithoutPrefix(key).split('.'); + const firstKey = keys[0]; + + let finalData = value; + if (Boxed.is(value)) { + finalData = transform(firstKey, new Boxed(value), value); + } else if (value instanceof YArray) { + finalData = transform(firstKey, value.toArray(), value); + } else if (value instanceof YText) { + const next = new Text(value); + finalData = transform(firstKey, next, value); + } else if (value instanceof YMap) { + throw new BlockSuiteError( + 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, root, path); + acc[key] = data; + } + if (index === allLength - 1) { + acc[key] = finalData; + } + return acc[key] as UnRecord; + }, root); + }); + + return root; + }; + + private _byPassYjs = false; + + private readonly _getProxy = ( + source: UnRecord, + root: UnRecord, + path?: string + ): UnRecord => { + return createProxy({ + yMap: this._ySource, + base: source, + root, + onDispose: this._onDispose, + shouldByPassSignal: () => this._skipNext, + byPassSignalUpdate: this._updateWithSkip, + shouldByPassYjs: () => this._byPassYjs, + basePath: path, + onChange: this._onChange, + transform: this._transform, + stashed: this._stashed, + initialized: () => this._initialized, + }); + }; + + private readonly _updateWithYjsSkip = (fn: () => void) => { + this._byPassYjs = true; + fn(); + this._byPassYjs = false; + }; + + constructor( + protected readonly _ySource: YMap, + private readonly _onDispose: Subject, + private readonly _onChange?: OnChange + ) { + super(); + this._initialized = false; + const source = this._createDefaultData(); + this._source = source; + + const proxy = this._getProxy(source, source); + + Object.entries(source).forEach(([key, value]) => { + const signalData = signal(value); + source[`${key}$`] = signalData; + const unsubscribe = signalData.subscribe(next => { + if (!this._initialized) { + return; + } + this._updateWithSkip(() => { + proxy[key] = next; + this._onChange?.(key, true); + }); + }); + const subscription = _onDispose.subscribe(() => { + subscription.unsubscribe(); + unsubscribe(); + }); + }); + + this._proxy = proxy; + this._ySource.observe(this._observer); + this._initialized = true; + } + + 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/flat-native-y/proxy.ts b/blocksuite/framework/store/src/reactive/flat-native-y/proxy.ts new file mode 100644 index 0000000000..bda501cffa --- /dev/null +++ b/blocksuite/framework/store/src/reactive/flat-native-y/proxy.ts @@ -0,0 +1,249 @@ +import { signal } from '@preact/signals-core'; + +import { Boxed } from '../boxed'; +import { isPureObject } from '../is-pure-object'; +import { native2Y } from '../native-y'; +import { Text } from '../text'; +import type { UnRecord } from '../types'; +import type { CreateProxyOptions } from './types'; +import { + getFirstKey, + isProxy, + keyWithoutPrefix, + keyWithPrefix, + markProxy, +} from './utils'; + +function initializeProxy(options: CreateProxyOptions) { + const { basePath, yMap, base, root } = options; + + Object.entries(base).forEach(([key, value]) => { + if (isPureObject(value) && !isProxy(value)) { + const proxy = createProxy({ + ...options, + yMap, + base: value as UnRecord, + root, + basePath: basePath ? `${basePath}.${key}` : key, + }); + base[key] = proxy; + } + }); +} + +export function createProxy(options: CreateProxyOptions): UnRecord { + const { + yMap, + base, + root, + onDispose, + shouldByPassSignal, + shouldByPassYjs, + byPassSignalUpdate, + basePath, + onChange, + initialized, + transform, + stashed, + } = options; + const isRoot = !basePath; + + if (isProxy(base)) { + return base; + } + + initializeProxy(options); + + 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 = getFirstKey(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; + const unsubscribe = signalData.subscribe(next => { + if (!initialized()) { + return; + } + byPassSignalUpdate(() => { + proxy[p] = next; + onChange?.(firstKey, true); + }); + }); + const subscription = onDispose.subscribe(() => { + subscription.unsubscribe(); + unsubscribe(); + }); + 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, true); + }); + }; + + if (isPureObject(value)) { + const syncYMap = () => { + if (shouldByPassYjs()) { + return; + } + yMap.forEach((_, key) => { + if (initialized() && keyWithoutPrefix(key).startsWith(fullPath)) { + yMap.delete(key); + } + }); + const run = (obj: object, basePath: string) => { + Object.entries(obj).forEach(([key, value]) => { + const fullPath = basePath ? `${basePath}.${key}` : key; + if (isPureObject(value)) { + run(value, fullPath); + } else { + list.push(() => { + if (value instanceof Text || Boxed.is(value)) { + value.bind(() => { + onChange?.(firstKey, true); + }); + } + yMap.set(keyWithPrefix(fullPath), native2Y(value)); + }); + } + }); + }; + run(value, fullPath); + if (list.length && initialized()) { + yMap.doc?.transact( + () => { + list.forEach(fn => fn()); + }, + { proxy: true } + ); + } + }; + + if (!isStashed) { + syncYMap(); + } + + const next = createProxy({ + ...options, + basePath: fullPath, + yMap, + base: value as UnRecord, + root, + }); + + const result = Reflect.set(target, p, next, receiver); + updateSignal(next); + return result; + } + + if (value instanceof Text || Boxed.is(value)) { + value.bind(() => { + onChange?.(firstKey, true); + }); + } + const yValue = native2Y(value); + const next = transform(firstKey, value, yValue); + if (!isStashed && initialized() && !shouldByPassYjs()) { + yMap.doc?.transact( + () => { + yMap.set(keyWithPrefix(fullPath), yValue); + }, + { proxy: true } + ); + } + + const result = Reflect.set(target, p, next, receiver); + updateSignal(next); + return result; + } + return Reflect.set(target, p, value, receiver); + }, + deleteProperty: (target, p) => { + if (typeof p === 'string') { + const fullPath = basePath ? `${basePath}.${p}` : p; + const firstKey = getFirstKey(fullPath); + const isStashed = stashed.has(firstKey); + + const updateSignal = () => { + if (shouldByPassSignal()) { + return; + } + + const signalKey = `${firstKey}$`; + if (!(signalKey in root)) { + if (!isRoot) { + return; + } + delete root[signalKey]; + return; + } + byPassSignalUpdate(() => { + const prev = root[firstKey]; + const next = isRoot + ? prev + : isPureObject(prev) + ? { ...prev } + : Array.isArray(prev) + ? [...prev] + : prev; + // @ts-expect-error allow magic props + root[signalKey].value = next; + onChange?.(firstKey, true); + }); + }; + + if (!isStashed && initialized() && !shouldByPassYjs()) { + yMap.doc?.transact( + () => { + const fullKey = keyWithPrefix(fullPath); + yMap.forEach((_, key) => { + if (key.startsWith(fullKey)) { + yMap.delete(key); + } + }); + }, + { proxy: true } + ); + } + + const result = Reflect.deleteProperty(target, p); + updateSignal(); + return result; + } + return Reflect.deleteProperty(target, p); + }, + }); + + markProxy(proxy); + + return proxy; +} diff --git a/blocksuite/framework/store/src/reactive/flat-native-y/types.ts b/blocksuite/framework/store/src/reactive/flat-native-y/types.ts new file mode 100644 index 0000000000..9a382348db --- /dev/null +++ b/blocksuite/framework/store/src/reactive/flat-native-y/types.ts @@ -0,0 +1,26 @@ +import type { Subject } from 'rxjs'; +import type { Map as YMap } from 'yjs'; + +import type { UnRecord } from '../types'; + +export type OnChange = (key: string, isLocal: boolean) => void; +export type Transform = ( + key: string, + value: unknown, + origin: unknown +) => unknown; + +export type CreateProxyOptions = { + yMap: YMap; + base: UnRecord; + root: UnRecord; + basePath?: string; + onChange?: OnChange; + transform: Transform; + onDispose: Subject; + shouldByPassSignal: () => boolean; + shouldByPassYjs: () => boolean; + byPassSignalUpdate: (fn: () => void) => void; + stashed: Set; + initialized: () => boolean; +}; diff --git a/blocksuite/framework/store/src/reactive/flat-native-y/utils.ts b/blocksuite/framework/store/src/reactive/flat-native-y/utils.ts new file mode 100644 index 0000000000..1a57309ac8 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/flat-native-y/utils.ts @@ -0,0 +1,42 @@ +import { SYS_KEYS } from '../../consts'; +import type { UnRecord } from '../types'; + +export const keyWithoutPrefix = (key: string) => key.replace(/(prop|sys):/, ''); + +export const keyWithPrefix = (key: string) => + SYS_KEYS.has(key) ? `sys:${key}` : `prop:${key}`; + +const proxySymbol = Symbol('proxy'); + +export function isProxy(value: unknown): boolean { + return proxySymbol in Object.getPrototypeOf(value); +} + +export function markProxy(value: UnRecord): UnRecord { + Object.setPrototypeOf(value, { + [proxySymbol]: true, + }); + return value; +} + +export function isEmptyObject(obj: UnRecord): boolean { + return Object.keys(obj).length === 0; +} + +export function deleteEmptyObject( + obj: UnRecord, + key: string, + parent: UnRecord +): void { + if (isEmptyObject(obj)) { + delete parent[key]; + } +} + +export function getFirstKey(key: string) { + const result = key.split('.').at(0); + if (!result) { + throw new Error(`Invalid key for: ${key}`); + } + return result; +} diff --git a/blocksuite/framework/store/src/reactive/index.ts b/blocksuite/framework/store/src/reactive/index.ts index 51d4a7b908..09c06baadd 100644 --- a/blocksuite/framework/store/src/reactive/index.ts +++ b/blocksuite/framework/store/src/reactive/index.ts @@ -1,5 +1,5 @@ export * from './boxed.js'; -export * from './flat-native-y.js'; +export * from './flat-native-y/index.js'; export * from './is-pure-object.js'; export * from './native-y.js'; export * from './proxy.js';