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)
This commit is contained in:
Saul-Mirone
2025-01-21 08:08:01 +00:00
parent 7400cf225f
commit 5783580054
4 changed files with 144 additions and 7 deletions

View File

@@ -12,7 +12,7 @@ import {
ZERO_WIDTH_NON_JOINER, ZERO_WIDTH_NON_JOINER,
ZERO_WIDTH_SPACE, ZERO_WIDTH_SPACE,
} from '@blocksuite/inline'; } from '@blocksuite/inline';
import { effect, signal } from '@preact/signals-core'; import { signal } from '@preact/signals-core';
import katex from 'katex'; import katex from 'katex';
import { css, html, render } from 'lit'; import { css, html, render } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
@@ -88,6 +88,8 @@ export class AffineLatexNode extends SignalWatcher(
readonly latex$ = signal(''); readonly latex$ = signal('');
readonly latexEditorSignal = signal('');
get deltaLatex() { get deltaLatex() {
return this.delta.attributes?.latex as string; return this.delta.attributes?.latex as string;
} }
@@ -100,11 +102,11 @@ export class AffineLatexNode extends SignalWatcher(
const result = super.connectedCallback(); const result = super.connectedCallback();
this.latex$.value = this.deltaLatex; this.latex$.value = this.deltaLatex;
this.latexEditorSignal.value = this.deltaLatex;
this.disposables.add( this.disposables.add(
effect(() => { this.latex$.subscribe(latex => {
const latex = this.latex$.value; this.latexEditorSignal.value = latex;
if (latex !== this.deltaLatex) { if (latex !== this.deltaLatex) {
this.editor.formatText( this.editor.formatText(
{ {
@@ -116,7 +118,11 @@ export class AffineLatexNode extends SignalWatcher(
} }
); );
} }
})
);
this.disposables.add(
this.latexEditorSignal.subscribe(latex => {
this.updateComplete this.updateComplete
.then(() => { .then(() => {
const latexContainer = this.latexContainer; const latexContainer = this.latexContainer;
@@ -187,7 +193,7 @@ export class AffineLatexNode extends SignalWatcher(
const portal = createLitPortal({ const portal = createLitPortal({
template: html`<latex-editor-menu template: html`<latex-editor-menu
.std=${this.std} .std=${this.std}
.latexSignal=${this.latex$} .latexSignal=${this.latexEditorSignal}
.abortController=${this._editorAbortController} .abortController=${this._editorAbortController}
></latex-editor-menu>`, ></latex-editor-menu>`,
container: blockComponent.host, container: blockComponent.host,
@@ -210,6 +216,20 @@ export class AffineLatexNode extends SignalWatcher(
'abort', 'abort',
() => { () => {
portal.remove(); 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 } { once: true }
); );

View File

@@ -27,12 +27,25 @@ const pageSchema = defineBlockSchema({
version: 1, version: 1,
}, },
}); });
const tableSchema = defineBlockSchema({
flavour: 'table',
props: () => ({
cols: {} as Record<string, { color: string }>,
rows: [] as Array<{ color: string }>,
}),
metadata: {
role: 'content',
version: 1,
},
});
type RootModel = SchemaToModel<typeof pageSchema>; type RootModel = SchemaToModel<typeof pageSchema>;
type TableModel = SchemaToModel<typeof tableSchema>;
function createTestOptions() { function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator(); const idGenerator = createAutoIncrementIdGenerator();
const schema = new Schema(); const schema = new Schema();
schema.register([pageSchema]); schema.register([pageSchema, tableSchema]);
return { id: 'test-collection', idGenerator, schema }; return { id: 'test-collection', idGenerator, schema };
} }
@@ -249,3 +262,102 @@ test('on change', () => {
foo: 0, 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<unknown>;
const getRowsArr = () => yBlock.get('prop:rows') as Y.Array<unknown>;
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<string>;
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);
});

View File

@@ -222,7 +222,7 @@ export class SyncController {
if (signalKey in this.model) { if (signalKey in this.model) {
this._mutex(() => { this._mutex(() => {
// @ts-expect-error allow magic props // @ts-expect-error allow magic props
this.model[signalKey].value = this.model[name]; this.model[signalKey].value = y2Native(value);
}); });
} }
}, },

View File

@@ -494,6 +494,9 @@ test.describe('edgeless text block', () => {
await page.locator('affine-latex-node').click(); await page.locator('affine-latex-node').click();
await waitNextFrame(page); await waitNextFrame(page);
await type(page, 'ccc'); await type(page, 'ccc');
const menu = page.locator('latex-editor-menu');
const confirm = menu.locator('.latex-editor-confirm');
await confirm.click();
await assertRichTextInlineDeltas( await assertRichTextInlineDeltas(
page, page,
[ [
@@ -507,6 +510,7 @@ test.describe('edgeless text block', () => {
1 1
); );
await page.locator('affine-latex-node').click();
await page.locator('.latex-editor-hint').click(); await page.locator('.latex-editor-hint').click();
await type(page, 'sss'); await type(page, 'sss');
await assertRichTextInlineDeltas( await assertRichTextInlineDeltas(
@@ -524,6 +528,7 @@ test.describe('edgeless text block', () => {
await page.locator('latex-editor-unit').click(); await page.locator('latex-editor-unit').click();
await selectAllByKeyboard(page); await selectAllByKeyboard(page);
await type(page, 'sss'); await type(page, 'sss');
await confirm.click();
await assertRichTextInlineDeltas( await assertRichTextInlineDeltas(
page, page,
[ [