refactor(editor): improve implementation of flat model (#10848)

This commit is contained in:
Saul-Mirone
2025-03-14 13:18:03 +00:00
parent 3b4453d2b8
commit 1ce290094e
8 changed files with 576 additions and 525 deletions

View File

@@ -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';

View File

@@ -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';

View File

@@ -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<void>;
shouldByPassSignal: () => boolean;
shouldByPassYjs: () => boolean;
byPassSignalUpdate: (fn: () => void) => void;
stashed: Set<string | number>;
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<unknown>,
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<unknown>
> {
protected readonly _proxy: UnRecord;
protected readonly _source: UnRecord;
protected readonly _options?: ProxyOptions<UnRecord>;
private readonly _initialized;
private readonly _observer = (event: YMapEvent<unknown>) => {
const yMap = this._ySource;
const proxy = this._proxy;
this._onObserve(event, () => {
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<unknown>,
private readonly _onDispose: Subject<void>,
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];
}
}

View File

@@ -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<unknown>
> {
protected readonly _proxy: UnRecord;
protected readonly _source: UnRecord;
protected readonly _options?: ProxyOptions<UnRecord>;
private readonly _initialized;
private readonly _observer = (event: YMapEvent<unknown>) => {
const yMap = this._ySource;
const proxy = this._proxy;
this._onObserve(event, () => {
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<unknown>,
private readonly _onDispose: Subject<void>,
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);
};
}

View File

@@ -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;
}

View File

@@ -0,0 +1,26 @@
import type { Subject } from 'rxjs';
import type { Map as YMap } from 'yjs';
import type { UnRecord } from '../types';
export type OnChange = (key: string, isLocal: boolean) => void;
export type Transform = (
key: string,
value: unknown,
origin: unknown
) => unknown;
export type CreateProxyOptions = {
yMap: YMap<unknown>;
base: UnRecord;
root: UnRecord;
basePath?: string;
onChange?: OnChange;
transform: Transform;
onDispose: Subject<void>;
shouldByPassSignal: () => boolean;
shouldByPassYjs: () => boolean;
byPassSignalUpdate: (fn: () => void) => void;
stashed: Set<string | number>;
initialized: () => boolean;
};

View File

@@ -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;
}

View File

@@ -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';