From 5783580054f5e085d528ce1b6e548d6c99a78124 Mon Sep 17 00:00:00 2001 From: Saul-Mirone Date: Tue, 21 Jan 2025 08:08:01 +0000 Subject: [PATCH] fix(editor): y reactive deep watch (#9818) Closes: [BS-2193](https://linear.app/affine-design/issue/BS-2193/fix-deep-watcher-of-reactive-yjs-data) --- .../presets/nodes/latex-node/latex-node.ts | 30 ++++- .../store/src/__tests__/block.unit.spec.ts | 114 +++++++++++++++++- .../store/src/model/block/sync-controller.ts | 2 +- .../edgeless/edgeless-text.spec.ts | 5 + 4 files changed, 144 insertions(+), 7 deletions(-) diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-node.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-node.ts index 0812896bb4..da518f78a2 100644 --- a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-node.ts +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-node.ts @@ -12,7 +12,7 @@ import { ZERO_WIDTH_NON_JOINER, ZERO_WIDTH_SPACE, } from '@blocksuite/inline'; -import { effect, signal } from '@preact/signals-core'; +import { signal } from '@preact/signals-core'; import katex from 'katex'; import { css, html, render } from 'lit'; import { property } from 'lit/decorators.js'; @@ -88,6 +88,8 @@ export class AffineLatexNode extends SignalWatcher( readonly latex$ = signal(''); + readonly latexEditorSignal = signal(''); + get deltaLatex() { return this.delta.attributes?.latex as string; } @@ -100,11 +102,11 @@ export class AffineLatexNode extends SignalWatcher( const result = super.connectedCallback(); this.latex$.value = this.deltaLatex; + this.latexEditorSignal.value = this.deltaLatex; this.disposables.add( - effect(() => { - const latex = this.latex$.value; - + this.latex$.subscribe(latex => { + this.latexEditorSignal.value = latex; if (latex !== this.deltaLatex) { this.editor.formatText( { @@ -116,7 +118,11 @@ export class AffineLatexNode extends SignalWatcher( } ); } + }) + ); + this.disposables.add( + this.latexEditorSignal.subscribe(latex => { this.updateComplete .then(() => { const latexContainer = this.latexContainer; @@ -187,7 +193,7 @@ export class AffineLatexNode extends SignalWatcher( const portal = createLitPortal({ template: html``, container: blockComponent.host, @@ -210,6 +216,20 @@ export class AffineLatexNode extends SignalWatcher( 'abort', () => { portal.remove(); + const latex = this.latexEditorSignal.peek(); + this.latex$.value = latex; + + if (latex !== this.deltaLatex) { + this.editor.formatText( + { + index: this.startOffset, + length: this.endOffset - this.startOffset, + }, + { + latex, + } + ); + } }, { once: true } ); diff --git a/blocksuite/framework/store/src/__tests__/block.unit.spec.ts b/blocksuite/framework/store/src/__tests__/block.unit.spec.ts index 720936c1d0..75896e60b8 100644 --- a/blocksuite/framework/store/src/__tests__/block.unit.spec.ts +++ b/blocksuite/framework/store/src/__tests__/block.unit.spec.ts @@ -27,12 +27,25 @@ const pageSchema = defineBlockSchema({ version: 1, }, }); + +const tableSchema = defineBlockSchema({ + flavour: 'table', + props: () => ({ + cols: {} as Record, + rows: [] as Array<{ color: string }>, + }), + metadata: { + role: 'content', + version: 1, + }, +}); type RootModel = SchemaToModel; +type TableModel = SchemaToModel; function createTestOptions() { const idGenerator = createAutoIncrementIdGenerator(); const schema = new Schema(); - schema.register([pageSchema]); + schema.register([pageSchema, tableSchema]); return { id: 'test-collection', idGenerator, schema }; } @@ -249,3 +262,102 @@ test('on change', () => { foo: 0, }); }); + +test('deep sync', () => { + const doc = createTestDoc(); + const yDoc = new Y.Doc(); + const yBlock = yDoc.getMap('yBlock') as YBlock; + yBlock.set('sys:id', '0'); + yBlock.set('sys:flavour', 'table'); + yBlock.set('sys:children', new Y.Array()); + + const onPropsUpdated = vi.fn(); + const block = new Block(doc.schema, yBlock, doc, { + onChange: onPropsUpdated, + }); + const model = block.model as TableModel; + expect(model.cols).toEqual({}); + expect(model.rows).toEqual([]); + + model.cols = { + '1': { color: 'red' }, + }; + const onColsUpdated = vi.fn(); + const onRowsUpdated = vi.fn(); + effect(() => { + onColsUpdated(model.cols$.value); + }); + effect(() => { + onRowsUpdated(model.rows$.value); + }); + const getColsMap = () => yBlock.get('prop:cols') as Y.Map; + const getRowsArr = () => yBlock.get('prop:rows') as Y.Array; + expect(getColsMap().toJSON()).toEqual({ + '1': { color: 'red' }, + }); + expect(model.cols$.value).toEqual({ + '1': { color: 'red' }, + }); + + onPropsUpdated.mockClear(); + onColsUpdated.mockClear(); + + model.cols['2'] = { color: 'blue' }; + expect(getColsMap().toJSON()).toEqual({ + '1': { color: 'red' }, + '2': { color: 'blue' }, + }); + expect(onColsUpdated).toHaveBeenCalledWith({ + '1': { color: 'red' }, + '2': { color: 'blue' }, + }); + expect(onPropsUpdated).toHaveBeenCalledTimes(1); + expect(onColsUpdated).toHaveBeenCalledTimes(1); + + onPropsUpdated.mockClear(); + onColsUpdated.mockClear(); + + const map = new Y.Map(); + map.set('color', 'green'); + getColsMap().set('3', map); + expect(onPropsUpdated).toHaveBeenCalledWith( + expect.anything(), + 'cols', + expect.anything() + ); + expect(onColsUpdated).toHaveBeenCalledWith({ + '1': { color: 'red' }, + '2': { color: 'blue' }, + '3': { color: 'green' }, + }); + expect(onPropsUpdated).toHaveBeenCalledTimes(1); + expect(onColsUpdated).toHaveBeenCalledTimes(1); + + onPropsUpdated.mockClear(); + onRowsUpdated.mockClear(); + + model.rows.push({ color: 'yellow' }); + expect(onPropsUpdated).toHaveBeenCalledWith( + expect.anything(), + 'rows', + expect.anything() + ); + expect(onRowsUpdated).toHaveBeenCalledWith([{ color: 'yellow' }]); + expect(onPropsUpdated).toHaveBeenCalledTimes(1); + expect(onRowsUpdated).toHaveBeenCalledTimes(1); + + onPropsUpdated.mockClear(); + onRowsUpdated.mockClear(); + + const row1 = getRowsArr().get(0) as Y.Map; + row1.set('color', 'green'); + expect(onRowsUpdated).toHaveBeenCalledWith([{ color: 'green' }]); + expect(onPropsUpdated).toHaveBeenCalledWith( + expect.anything(), + 'rows', + expect.anything() + ); + expect(model.rows$.value).toEqual([{ color: 'green' }]); + expect(onPropsUpdated).toHaveBeenCalledTimes(1); + expect(onRowsUpdated).toHaveBeenCalledTimes(1); +}); diff --git a/blocksuite/framework/store/src/model/block/sync-controller.ts b/blocksuite/framework/store/src/model/block/sync-controller.ts index 5126e0467f..c8aad4f7fe 100644 --- a/blocksuite/framework/store/src/model/block/sync-controller.ts +++ b/blocksuite/framework/store/src/model/block/sync-controller.ts @@ -222,7 +222,7 @@ export class SyncController { if (signalKey in this.model) { this._mutex(() => { // @ts-expect-error allow magic props - this.model[signalKey].value = this.model[name]; + this.model[signalKey].value = y2Native(value); }); } }, diff --git a/blocksuite/tests-legacy/edgeless/edgeless-text.spec.ts b/blocksuite/tests-legacy/edgeless/edgeless-text.spec.ts index e97323d2b4..852c47f7aa 100644 --- a/blocksuite/tests-legacy/edgeless/edgeless-text.spec.ts +++ b/blocksuite/tests-legacy/edgeless/edgeless-text.spec.ts @@ -494,6 +494,9 @@ test.describe('edgeless text block', () => { await page.locator('affine-latex-node').click(); await waitNextFrame(page); await type(page, 'ccc'); + const menu = page.locator('latex-editor-menu'); + const confirm = menu.locator('.latex-editor-confirm'); + await confirm.click(); await assertRichTextInlineDeltas( page, [ @@ -507,6 +510,7 @@ test.describe('edgeless text block', () => { 1 ); + await page.locator('affine-latex-node').click(); await page.locator('.latex-editor-hint').click(); await type(page, 'sss'); await assertRichTextInlineDeltas( @@ -524,6 +528,7 @@ test.describe('edgeless text block', () => { await page.locator('latex-editor-unit').click(); await selectAllByKeyboard(page); await type(page, 'sss'); + await confirm.click(); await assertRichTextInlineDeltas( page, [