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:
@@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user