feat(editor): add isLocal flag in blockUpdated subject (#10799)

This commit is contained in:
Saul-Mirone
2025-03-13 05:33:06 +00:00
parent c023b724d0
commit 250f3f1efd
15 changed files with 167 additions and 119 deletions

View File

@@ -267,20 +267,20 @@ test('on change', () => {
const model = block.model as RootModel; const model = block.model as RootModel;
model.title = internalPrimitives.Text('abc'); model.title = internalPrimitives.Text('abc');
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'title'); expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'title', true);
expect(model.title$.value.toDelta()).toEqual([{ insert: 'abc' }]); expect(model.title$.value.toDelta()).toEqual([{ insert: 'abc' }]);
onPropsUpdated.mockClear(); onPropsUpdated.mockClear();
model.title.insert('d', 1); model.title.insert('d', 1);
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'title'); expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'title', true);
expect(model.title$.value.toDelta()).toEqual([{ insert: 'adbc' }]); expect(model.title$.value.toDelta()).toEqual([{ insert: 'adbc' }]);
onPropsUpdated.mockClear(); onPropsUpdated.mockClear();
model.boxed.getValue()!.set('foo', 0); model.boxed.getValue()!.set('foo', 0);
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'boxed'); expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'boxed', true);
expect(model.boxed$.value.getValue()!.toJSON()).toEqual({ expect(model.boxed$.value.getValue()!.toJSON()).toEqual({
foo: 0, foo: 0,
}); });
@@ -343,7 +343,7 @@ test('deep sync', () => {
const map = new Y.Map(); const map = new Y.Map();
map.set('color', 'green'); map.set('color', 'green');
getColsMap().set('3', map); getColsMap().set('3', map);
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'cols'); expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'cols', true);
expect(onColsUpdated).toHaveBeenCalledWith({ expect(onColsUpdated).toHaveBeenCalledWith({
'1': { color: 'red' }, '1': { color: 'red' },
'2': { color: 'blue' }, '2': { color: 'blue' },
@@ -356,7 +356,7 @@ test('deep sync', () => {
onRowsUpdated.mockClear(); onRowsUpdated.mockClear();
model.rows.push({ color: 'yellow' }); model.rows.push({ color: 'yellow' });
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'rows'); expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'rows', true);
expect(onRowsUpdated).toHaveBeenCalledWith([{ color: 'yellow' }]); expect(onRowsUpdated).toHaveBeenCalledWith([{ color: 'yellow' }]);
expect(onPropsUpdated).toHaveBeenCalledTimes(1); expect(onPropsUpdated).toHaveBeenCalledTimes(1);
expect(onRowsUpdated).toHaveBeenCalledTimes(1); expect(onRowsUpdated).toHaveBeenCalledTimes(1);
@@ -367,7 +367,7 @@ test('deep sync', () => {
const row1 = getRowsArr().get(0) as Y.Map<string>; const row1 = getRowsArr().get(0) as Y.Map<string>;
row1.set('color', 'green'); row1.set('color', 'green');
expect(onRowsUpdated).toHaveBeenCalledWith([{ color: 'green' }]); expect(onRowsUpdated).toHaveBeenCalledWith([{ color: 'green' }]);
expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'rows'); expect(onPropsUpdated).toHaveBeenCalledWith(expect.anything(), 'rows', true);
expect(model.rows$.value).toEqual([{ color: 'green' }]); expect(model.rows$.value).toEqual([{ color: 'green' }]);
expect(onPropsUpdated).toHaveBeenCalledTimes(1); expect(onPropsUpdated).toHaveBeenCalledTimes(1);
expect(onRowsUpdated).toHaveBeenCalledTimes(1); expect(onRowsUpdated).toHaveBeenCalledTimes(1);
@@ -413,7 +413,7 @@ describe('flat', () => {
}); });
expect(onColUpdated).toHaveBeenCalledTimes(1); expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'cols'); expect(onChange).toHaveBeenCalledWith(expect.anything(), 'cols', true);
model.props.cols.a.color = 'black'; model.props.cols.a.color = 'black';
expect(yBlock.get('prop:cols.a.color')).toBe('black'); expect(yBlock.get('prop:cols.a.color')).toBe('black');
@@ -432,7 +432,7 @@ describe('flat', () => {
}); });
expect(onColUpdated).toHaveBeenCalledTimes(1); expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'cols'); expect(onChange).toHaveBeenCalledWith(expect.anything(), 'cols', true);
onChange.mockClear(); onChange.mockClear();
onColUpdated.mockClear(); onColUpdated.mockClear();
@@ -442,7 +442,7 @@ describe('flat', () => {
expect(model.props.cols$.value).toEqual({ a: {} }); expect(model.props.cols$.value).toEqual({ a: {} });
expect(onColUpdated).toHaveBeenCalledTimes(1); expect(onColUpdated).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'cols'); expect(onChange).toHaveBeenCalledWith(expect.anything(), 'cols', true);
model.props.cols = { model.props.cols = {
a: { color: 'red' }, a: { color: 'red' },
@@ -462,7 +462,7 @@ describe('flat', () => {
expect((yBlock.get('prop:title') as Y.Text).toJSON()).toBe('test'); expect((yBlock.get('prop:title') as Y.Text).toJSON()).toBe('test');
expect(model.props.title$.value.toDelta()).toEqual([{ insert: 'test' }]); expect(model.props.title$.value.toDelta()).toEqual([{ insert: 'test' }]);
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'title'); expect(onChange).toHaveBeenCalledWith(expect.anything(), 'title', true);
onChange.mockClear(); onChange.mockClear();
model.props.labels.push('test'); model.props.labels.push('test');
@@ -470,18 +470,18 @@ describe('flat', () => {
expect(getLabels().toJSON()).toEqual(['test']); expect(getLabels().toJSON()).toEqual(['test']);
expect(model.props.labels$.value).toEqual(['test']); expect(model.props.labels$.value).toEqual(['test']);
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'labels'); expect(onChange).toHaveBeenCalledWith(expect.anything(), 'labels', true);
onChange.mockClear(); onChange.mockClear();
model.props.labels$.value = ['test2']; model.props.labels$.value = ['test2'];
expect(getLabels().toJSON()).toEqual(['test2']); expect(getLabels().toJSON()).toEqual(['test2']);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'labels'); expect(onChange).toHaveBeenCalledWith(expect.anything(), 'labels', true);
onChange.mockClear(); onChange.mockClear();
model.props.labels.splice(0, 1); model.props.labels.splice(0, 1);
expect(getLabels().toJSON()).toEqual([]); expect(getLabels().toJSON()).toEqual([]);
expect(model.props.labels$.value).toEqual([]); expect(model.props.labels$.value).toEqual([]);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'labels'); expect(onChange).toHaveBeenCalledWith(expect.anything(), 'labels', true);
model.props.textCols = { model.props.textCols = {
a: internalPrimitives.Text(), a: internalPrimitives.Text(),
@@ -489,7 +489,7 @@ describe('flat', () => {
onChange.mockClear(); onChange.mockClear();
model.props.textCols.a.insert('test', 0); model.props.textCols.a.insert('test', 0);
expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(expect.anything(), 'textCols'); expect(onChange).toHaveBeenCalledWith(expect.anything(), 'textCols', true);
expect((yBlock.get('prop:textCols.a') as Y.Text).toJSON()).toBe('test'); expect((yBlock.get('prop:textCols.a') as Y.Text).toJSON()).toBe('test');
expect(model.props.textCols$.value.a.toDelta()).toEqual([ expect(model.props.textCols$.value.a.toDelta()).toEqual([
{ insert: 'test' }, { insert: 'test' },

View File

@@ -43,11 +43,11 @@ export class Block {
) { ) {
const onChange = !options.onChange const onChange = !options.onChange
? undefined ? undefined
: (key: string) => { : (key: string, isLocal: boolean) => {
if (!this._syncController || !this.model) { if (!this._syncController || !this.model) {
return; return;
} }
options.onChange?.(this, key); options.onChange?.(this, key, isLocal);
}; };
const flavour = yBlock.get('sys:flavour') as string; const flavour = yBlock.get('sys:flavour') as string;
const blockSchema = this.schema.get(flavour); const blockSchema = this.schema.get(flavour);

View File

@@ -25,7 +25,7 @@ export class FlatSyncController {
readonly schema: Schema, readonly schema: Schema,
readonly yBlock: YBlock, readonly yBlock: YBlock,
readonly doc?: Store, readonly doc?: Store,
readonly onChange?: (key: string) => void readonly onChange?: (key: string, isLocal: boolean) => void
) { ) {
const { id, flavour, version, yChildren, props } = this._parseYBlock(); const { id, flavour, version, yChildren, props } = this._parseYBlock();

View File

@@ -42,6 +42,13 @@ export class SyncController {
if (!type) { if (!type) {
return; return;
} }
const isLocal =
!this.yBlock.doc ||
!event.transaction.origin ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this.yBlock.doc.clientID;
if (type.action === 'update' || type.action === 'add') { if (type.action === 'update' || type.action === 'add') {
const value = this.yBlock.get(key); const value = this.yBlock.get(key);
const keyName = key.replace('prop:', ''); const keyName = key.replace('prop:', '');
@@ -57,7 +64,7 @@ export class SyncController {
} }
}); });
}); });
this.onChange?.(keyName); this.onChange?.(keyName, isLocal);
return; return;
} }
if (type.action === 'delete') { if (type.action === 'delete') {
@@ -70,7 +77,7 @@ export class SyncController {
this.model[`${keyName}$`].value = undefined; this.model[`${keyName}$`].value = undefined;
} }
}); });
this.onChange?.(keyName); this.onChange?.(keyName, isLocal);
return; return;
} }
}); });
@@ -105,7 +112,7 @@ export class SyncController {
readonly schema: Schema, readonly schema: Schema,
readonly yBlock: YBlock, readonly yBlock: YBlock,
readonly doc?: Store, readonly doc?: Store,
readonly onChange?: (key: string) => void readonly onChange?: (key: string, isLocal: boolean) => void
) { ) {
const { id, flavour, version, yChildren, props } = this._parseYBlock(); const { id, flavour, version, yChildren, props } = this._parseYBlock();
@@ -178,7 +185,7 @@ export class SyncController {
if (this._stashed.has(p)) { if (this._stashed.has(p)) {
setValue(target, p, value); setValue(target, p, value);
const result = Reflect.set(target, p, value, receiver); const result = Reflect.set(target, p, value, receiver);
this.onChange?.(p); this.onChange?.(p, true);
return result; return result;
} }
@@ -222,8 +229,8 @@ export class SyncController {
private _getPropsProxy(name: string, value: unknown) { private _getPropsProxy(name: string, value: unknown) {
return createYProxy(value, { return createYProxy(value, {
onChange: () => { onChange: (_, isLocal) => {
this.onChange?.(name); this.onChange?.(name, isLocal);
const signalKey = `${name}$`; const signalKey = `${name}$`;
if (signalKey in this.model) { if (signalKey in this.model) {
this._mutex(() => { this._mutex(() => {
@@ -344,12 +351,12 @@ export class SyncController {
}, },
set: (target, p, value, receiver) => { set: (target, p, value, receiver) => {
const result = Reflect.set(target, p, value, receiver); const result = Reflect.set(target, p, value, receiver);
this.onChange?.(prop); this.onChange?.(prop, true);
return result; return result;
}, },
deleteProperty: (target, p) => { deleteProperty: (target, p) => {
const result = Reflect.deleteProperty(target, p); const result = Reflect.deleteProperty(target, p);
this.onChange?.(prop); this.onChange?.(prop, true);
return result; return result;
}, },
}); });
@@ -365,12 +372,12 @@ export class SyncController {
return Reflect.set(target, p, value, receiver); return Reflect.set(target, p, value, receiver);
} }
const result = Reflect.set(target, p, value, receiver); const result = Reflect.set(target, p, value, receiver);
this.onChange?.(prop); this.onChange?.(prop, true);
return result; return result;
}, },
deleteProperty: (target, p) => { deleteProperty: (target, p) => {
const result = Reflect.deleteProperty(target, p); const result = Reflect.deleteProperty(target, p);
this.onChange?.(p as string); this.onChange?.(p as string, true);
return result; return result;
}, },
}); });

View File

@@ -10,7 +10,7 @@ export type YBlock = Y.Map<unknown> & {
}; };
export type BlockOptions = { export type BlockOptions = {
onChange?: (block: Block, key: string) => void; onChange?: (block: Block, key: string, isLocal: boolean) => void;
}; };
export type BlockSysProps = { export type BlockSysProps = {

View File

@@ -29,10 +29,12 @@ export interface Doc {
| { | {
type: 'add'; type: 'add';
id: string; id: string;
isLocal: boolean;
} }
| { | {
type: 'delete'; type: 'delete';
id: string; id: string;
isLocal: boolean;
} }
>; >;
}; };

View File

@@ -34,6 +34,31 @@ export type StoreOptions = {
extensions?: ExtensionType[]; extensions?: ExtensionType[];
}; };
export type BlockUpdatedPayload =
| {
type: 'add';
id: string;
isLocal: boolean;
init: boolean;
flavour: string;
model: BlockModel;
}
| {
type: 'delete';
id: string;
isLocal: boolean;
flavour: string;
parent: string;
model: BlockModel;
}
| {
type: 'update';
id: string;
isLocal: boolean;
flavour: string;
props: { key: string };
};
const internalExtensions = [StoreSelectionExtension]; const internalExtensions = [StoreSelectionExtension];
export class Store { export class Store {
@@ -76,28 +101,7 @@ export class Store {
*/ */
rootAdded: Subject<string>; rootAdded: Subject<string>;
rootDeleted: Subject<string>; rootDeleted: Subject<string>;
blockUpdated: Subject< blockUpdated: Subject<BlockUpdatedPayload>;
| {
type: 'add';
id: string;
init: boolean;
flavour: string;
model: BlockModel;
}
| {
type: 'delete';
id: string;
flavour: string;
parent: string;
model: BlockModel;
}
| {
type: 'update';
id: string;
flavour: string;
props: { key: string };
}
>;
}; };
updateBlock: { updateBlock: {
@@ -357,7 +361,7 @@ export class Store {
if (id in this._blocks.peek()) { if (id in this._blocks.peek()) {
return; return;
} }
this._onBlockAdded(id, true); this._onBlockAdded(id, false, true);
}); });
this._subscribeToSlots(); this._subscribeToSlots();
@@ -365,31 +369,18 @@ export class Store {
private readonly _subscribeToSlots = () => { private readonly _subscribeToSlots = () => {
this.disposableGroup.add( this.disposableGroup.add(
this._doc.slots.yBlockUpdated.subscribe( this._doc.slots.yBlockUpdated.subscribe(({ type, id, isLocal }) => {
({ type, id }: { type: string; id: string }) => { switch (type) {
switch (type) { case 'add': {
case 'add': { this._onBlockAdded(id, isLocal, false);
this._onBlockAdded(id); return;
return; }
} case 'delete': {
case 'delete': { this._onBlockRemoved(id, isLocal);
this._onBlockRemoved(id); return;
return;
}
case 'update': {
const block = this.getBlock(id);
if (!block) return;
this.slots.blockUpdated.next({
type: 'update',
id,
flavour: block.flavour,
props: { key: 'content' },
});
return;
}
} }
} }
) })
); );
this.disposableGroup.add(this.slots.ready); this.disposableGroup.add(this.slots.ready);
this.disposableGroup.add(this.slots.blockUpdated); this.disposableGroup.add(this.slots.blockUpdated);
@@ -414,7 +405,7 @@ export class Store {
return fn(parent, index); return fn(parent, index);
} }
private _onBlockAdded(id: string, init = false) { private _onBlockAdded(id: string, isLocal: boolean, init: boolean) {
try { try {
if (id in this._blocks.peek()) { if (id in this._blocks.peek()) {
return; return;
@@ -426,7 +417,7 @@ export class Store {
} }
const options: BlockOptions = { const options: BlockOptions = {
onChange: (block, key) => { onChange: (block, key, isLocal) => {
if (key) { if (key) {
block.model.propsUpdated.next({ key }); block.model.propsUpdated.next({ key });
} }
@@ -436,6 +427,7 @@ export class Store {
id, id,
flavour: block.flavour, flavour: block.flavour,
props: { key }, props: { key },
isLocal,
}); });
}, },
}; };
@@ -459,6 +451,7 @@ export class Store {
init, init,
flavour: block.model.flavour, flavour: block.model.flavour,
model: block.model, model: block.model,
isLocal,
}); });
} catch (e) { } catch (e) {
console.error('An error occurred while adding block:'); console.error('An error occurred while adding block:');
@@ -466,7 +459,7 @@ export class Store {
} }
} }
private _onBlockRemoved(id: string) { private _onBlockRemoved(id: string, isLocal: boolean) {
try { try {
const block = this.getBlock(id); const block = this.getBlock(id);
if (!block) return; if (!block) return;
@@ -481,6 +474,7 @@ export class Store {
flavour: block.flavour, flavour: block.flavour,
parent: this.getParent(block.model)?.id ?? '', parent: this.getParent(block.model)?.id ?? '',
model: block.model, model: block.model,
isLocal,
}); });
const { [id]: _, ...blocks } = this._blocks.peek(); const { [id]: _, ...blocks } = this._blocks.peek();

View File

@@ -1,13 +1,15 @@
import type { Doc as YDoc, YEvent } from 'yjs'; import * as Y from 'yjs';
import { UndoManager } from 'yjs';
import type { ProxyOptions } from './types'; import type { ProxyOptions } from './types';
export abstract class BaseReactiveYData<T, Y> { export abstract class BaseReactiveYData<
T,
YSource extends Y.AbstractType<any>,
> {
protected _getOrigin = ( protected _getOrigin = (
doc: YDoc doc: Y.Doc
): { ): {
doc: YDoc; doc: Y.Doc;
proxy: true; proxy: true;
target: BaseReactiveYData<any, any>; target: BaseReactiveYData<any, any>;
@@ -19,16 +21,24 @@ export abstract class BaseReactiveYData<T, Y> {
}; };
}; };
protected _onObserve = (event: YEvent<any>, handler: () => void) => { protected _onObserve = (event: Y.YEvent<any>, handler: () => void) => {
if ( if (
event.transaction.origin?.proxy !== true && event.transaction.origin?.proxy !== true &&
(!event.transaction.local || (!event.transaction.local ||
event.transaction.origin instanceof UndoManager) event.transaction.origin instanceof Y.UndoManager)
) { ) {
handler(); handler();
} }
this._options?.onChange?.(this._proxy); const isLocal =
!event.transaction.origin ||
!this._ySource.doc ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this._ySource.doc.clientID;
this._options?.onChange?.(this._proxy, isLocal);
}; };
protected abstract readonly _options?: ProxyOptions<T>; protected abstract readonly _options?: ProxyOptions<T>;
@@ -41,7 +51,7 @@ export abstract class BaseReactiveYData<T, Y> {
protected readonly _stashed = new Set<string | number>(); protected readonly _stashed = new Set<string | number>();
protected _transact = (doc: YDoc, fn: () => void) => { protected _transact = (doc: Y.Doc, fn: () => void) => {
doc.transact(fn, this._getOrigin(doc)); doc.transact(fn, this._getOrigin(doc));
}; };
@@ -54,7 +64,7 @@ export abstract class BaseReactiveYData<T, Y> {
this._skipNext = false; this._skipNext = false;
}; };
protected abstract readonly _ySource: Y; protected abstract readonly _ySource: YSource;
get proxy() { get proxy() {
return this._proxy; return this._proxy;

View File

@@ -2,7 +2,7 @@ import * as Y from 'yjs';
import { NATIVE_UNIQ_IDENTIFIER } from '../consts.js'; import { NATIVE_UNIQ_IDENTIFIER } from '../consts.js';
export type OnBoxedChange = (data: unknown) => void; export type OnBoxedChange = (data: unknown, isLocal: boolean) => void;
export class Boxed<T = unknown> { export class Boxed<T = unknown> {
static from = <T>(map: Y.Map<T>, onChange?: OnBoxedChange): Boxed<T> => { static from = <T>(map: Y.Map<T>, onChange?: OnBoxedChange): Boxed<T> => {
@@ -44,8 +44,17 @@ export class Boxed<T = unknown> {
this._map.set('type', NATIVE_UNIQ_IDENTIFIER as T); this._map.set('type', NATIVE_UNIQ_IDENTIFIER as T);
this._map.set('value', value); this._map.set('value', value);
} }
this._map.observeDeep(() => { this._map.observeDeep(events => {
this._onChange?.(this.getValue()); events.forEach(event => {
const isLocal =
!event.transaction.origin ||
!this._map.doc ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this._map.doc.clientID;
this._onChange?.(this.getValue(), isLocal);
});
}); });
} }

View File

@@ -22,7 +22,7 @@ const keyWithoutPrefix = (key: string) => key.replace(/(prop|sys):/, '');
const keyWithPrefix = (key: string) => const keyWithPrefix = (key: string) =>
SYS_KEYS.has(key) ? `sys:${key}` : `prop:${key}`; SYS_KEYS.has(key) ? `sys:${key}` : `prop:${key}`;
type OnChange = (key: string) => void; type OnChange = (key: string, isLocal: boolean) => void;
type Transform = (key: string, value: unknown, origin: unknown) => unknown; type Transform = (key: string, value: unknown, origin: unknown) => unknown;
type CreateProxyOptions = { type CreateProxyOptions = {
@@ -119,7 +119,7 @@ function createProxy(
} }
byPassSignalUpdate(() => { byPassSignalUpdate(() => {
proxy[p] = next; proxy[p] = next;
onChange?.(firstKey); onChange?.(firstKey, true);
}); });
}); });
const subscription = onDispose.subscribe(() => { const subscription = onDispose.subscribe(() => {
@@ -139,7 +139,7 @@ function createProxy(
: prev; : prev;
// @ts-expect-error allow magic props // @ts-expect-error allow magic props
root[signalKey].value = next; root[signalKey].value = next;
onChange?.(firstKey); onChange?.(firstKey, true);
}); });
}; };
@@ -162,7 +162,7 @@ function createProxy(
list.push(() => { list.push(() => {
if (value instanceof Text || Boxed.is(value)) { if (value instanceof Text || Boxed.is(value)) {
value.bind(() => { value.bind(() => {
onChange?.(firstKey); onChange?.(firstKey, true);
}); });
} }
yMap.set(keyWithPrefix(fullPath), native2Y(value)); yMap.set(keyWithPrefix(fullPath), native2Y(value));
@@ -197,7 +197,7 @@ function createProxy(
if (value instanceof Text || Boxed.is(value)) { if (value instanceof Text || Boxed.is(value)) {
value.bind(() => { value.bind(() => {
onChange?.(firstKey); onChange?.(firstKey, true);
}); });
} }
const yValue = native2Y(value); const yValue = native2Y(value);
@@ -251,7 +251,7 @@ function createProxy(
: prev; : prev;
// @ts-expect-error allow magic props // @ts-expect-error allow magic props
root[signalKey].value = next; root[signalKey].value = next;
onChange?.(firstKey); onChange?.(firstKey, true);
}); });
}; };
@@ -324,6 +324,7 @@ export class ReactiveFlatYMap extends BaseReactiveYData<
return acc[key] as UnRecord; return acc[key] as UnRecord;
}, proxy as UnRecord); }, proxy as UnRecord);
}); });
this._onChange?.(firstKey, false);
return; return;
} }
if (type.action === 'delete') { if (type.action === 'delete') {
@@ -390,8 +391,8 @@ export class ReactiveFlatYMap extends BaseReactiveYData<
}; };
private readonly _getPropOnChange = (key: string) => { private readonly _getPropOnChange = (key: string) => {
return () => { return (_: unknown, isLocal: boolean) => {
this._onChange?.(key); this._onChange?.(key, isLocal);
}; };
}; };
@@ -485,7 +486,7 @@ export class ReactiveFlatYMap extends BaseReactiveYData<
} }
this._updateWithSkip(() => { this._updateWithSkip(() => {
proxy[key] = next; proxy[key] = next;
this._onChange?.(key); this._onChange?.(key, true);
}); });
}); });
const subscription = _onDispose.subscribe(() => { const subscription = _onDispose.subscribe(() => {

View File

@@ -62,7 +62,7 @@ export class ReactiveYArray extends BaseReactiveYData<
if (this._stashed.has(index)) { if (this._stashed.has(index)) {
const result = Reflect.set(target, p, value, receiver); const result = Reflect.set(target, p, value, receiver);
this._options.onChange?.(this._proxy); this._options.onChange?.(this._proxy, true);
return result; return result;
} }
@@ -196,7 +196,7 @@ export class ReactiveYMap extends BaseReactiveYData<UnRecord, YMap<unknown>> {
if (this._stashed.has(p)) { if (this._stashed.has(p)) {
const result = Reflect.set(target, p, value, receiver); const result = Reflect.set(target, p, value, receiver);
this._options.onChange?.(this._proxy); this._options.onChange?.(this._proxy, true);
return result; return result;
} }

View File

@@ -13,7 +13,7 @@ export type DeltaOperation = {
retain?: number; retain?: number;
} & OptionalAttributes; } & OptionalAttributes;
export type OnTextChange = (data: Y.Text) => void; export type OnTextChange = (data: Y.Text, isLocal: boolean) => void;
export class Text { export class Text {
private readonly _deltas$: Signal<DeltaOperation[]>; private readonly _deltas$: Signal<DeltaOperation[]>;
@@ -67,10 +67,17 @@ export class Text {
this._length$ = signal(length); this._length$ = signal(length);
this._deltas$ = signal(this._yText.doc ? this._yText.toDelta() : []); this._deltas$ = signal(this._yText.doc ? this._yText.toDelta() : []);
this._yText.observe(() => { this._yText.observe(event => {
const isLocal =
!event.transaction.origin ||
!this._yText.doc ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this._yText.doc.clientID;
this._length$.value = this._yText.length; this._length$.value = this._yText.length;
this._deltas$.value = this._yText.toDelta(); this._deltas$.value = this._yText.toDelta();
this._onChange?.(this._yText); this._onChange?.(this._yText, isLocal);
}); });
} }

View File

@@ -15,5 +15,5 @@ export type TransformOptions = {
}; };
export type ProxyOptions<T> = { export type ProxyOptions<T> = {
onChange?: (data: T) => void; onChange?: (data: T, isLocal: boolean) => void;
}; };

View File

@@ -110,10 +110,12 @@ export class TestDoc implements Doc {
| { | {
type: 'add'; type: 'add';
id: string; id: string;
isLocal: boolean;
} }
| { | {
type: 'delete'; type: 'delete';
id: string; id: string;
isLocal: boolean;
} }
>(), >(),
}; };
@@ -185,12 +187,12 @@ export class TestDoc implements Doc {
return (readonly?.toString() as 'true' | 'false') ?? 'false'; return (readonly?.toString() as 'true' | 'false') ?? 'false';
} }
private _handleYBlockAdd(id: string) { private _handleYBlockAdd(id: string, isLocal: boolean) {
this.slots.yBlockUpdated.next({ type: 'add', id }); this.slots.yBlockUpdated.next({ type: 'add', id, isLocal });
} }
private _handleYBlockDelete(id: string) { private _handleYBlockDelete(id: string, isLocal: boolean) {
this.slots.yBlockUpdated.next({ type: 'delete', id }); this.slots.yBlockUpdated.next({ type: 'delete', id, isLocal });
} }
private _handleYEvent(event: Y.YEvent<YBlock | Y.Text | Y.Array<unknown>>) { private _handleYEvent(event: Y.YEvent<YBlock | Y.Text | Y.Array<unknown>>) {
@@ -198,14 +200,21 @@ export class TestDoc implements Doc {
if (event.target !== this._yBlocks) { if (event.target !== this._yBlocks) {
return; return;
} }
const isLocal =
!event.transaction.origin ||
!this._yBlocks.doc ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this._yBlocks.doc.clientID;
event.keys.forEach((value, id) => { event.keys.forEach((value, id) => {
try { try {
if (value.action === 'add') { if (value.action === 'add') {
this._handleYBlockAdd(id); this._handleYBlockAdd(id, isLocal);
return; return;
} }
if (value.action === 'delete') { if (value.action === 'delete') {
this._handleYBlockDelete(id); this._handleYBlockDelete(id, isLocal);
return; return;
} }
} catch (e) { } catch (e) {
@@ -318,7 +327,7 @@ export class TestDoc implements Doc {
this._initYBlocks(); this._initYBlocks();
this._yBlocks.forEach((_, id) => { this._yBlocks.forEach((_, id) => {
this._handleYBlockAdd(id); this._handleYBlockAdd(id, false);
}); });
initFn?.(); initFn?.();

View File

@@ -109,10 +109,12 @@ export class DocImpl implements Doc {
| { | {
type: 'add'; type: 'add';
id: string; id: string;
isLocal: boolean;
} }
| { | {
type: 'delete'; type: 'delete';
id: string; id: string;
isLocal: boolean;
} }
>(), >(),
}; };
@@ -175,12 +177,12 @@ export class DocImpl implements Doc {
return (readonly?.toString() as 'true' | 'false') ?? 'false'; return (readonly?.toString() as 'true' | 'false') ?? 'false';
} }
private _handleYBlockAdd(id: string) { private _handleYBlockAdd(id: string, isLocal: boolean) {
this.slots.yBlockUpdated.next({ type: 'add', id }); this.slots.yBlockUpdated.next({ type: 'add', id, isLocal });
} }
private _handleYBlockDelete(id: string) { private _handleYBlockDelete(id: string, isLocal: boolean) {
this.slots.yBlockUpdated.next({ type: 'delete', id }); this.slots.yBlockUpdated.next({ type: 'delete', id, isLocal });
} }
private _handleYEvent(event: Y.YEvent<YBlock | Y.Text | Y.Array<unknown>>) { private _handleYEvent(event: Y.YEvent<YBlock | Y.Text | Y.Array<unknown>>) {
@@ -188,14 +190,21 @@ export class DocImpl implements Doc {
if (event.target !== this._yBlocks) { if (event.target !== this._yBlocks) {
return; return;
} }
const isLocal =
!event.transaction.origin ||
!this._yBlocks.doc ||
event.transaction.origin instanceof Y.UndoManager ||
event.transaction.origin.proxy
? true
: event.transaction.origin === this._yBlocks.doc.clientID;
event.keys.forEach((value, id) => { event.keys.forEach((value, id) => {
try { try {
if (value.action === 'add') { if (value.action === 'add') {
this._handleYBlockAdd(id); this._handleYBlockAdd(id, isLocal);
return; return;
} }
if (value.action === 'delete') { if (value.action === 'delete') {
this._handleYBlockDelete(id); this._handleYBlockDelete(id, isLocal);
return; return;
} }
} catch (e) { } catch (e) {
@@ -317,7 +326,7 @@ export class DocImpl implements Doc {
this._initYBlocks(); this._initYBlocks();
this._yBlocks.forEach((_, id) => { this._yBlocks.forEach((_, id) => {
this._handleYBlockAdd(id); this._handleYBlockAdd(id, false);
}); });
initFn?.(); initFn?.();