Skip to content

Commit

Permalink
feat(editor): flat block data
Browse files Browse the repository at this point in the history
  • Loading branch information
Saul-Mirone committed Jan 23, 2025
1 parent 862a9d0 commit ac0f05a
Show file tree
Hide file tree
Showing 12 changed files with 384 additions and 162 deletions.
40 changes: 39 additions & 1 deletion blocksuite/framework/store/src/__tests__/block.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,32 @@ const tableSchema = defineBlockSchema({
cols: {} as Record<string, { color: string }>,
rows: [] as Array<{ color: string }>,
}),
metadata: {
role: 'content',
version: 1,
isFlatData: true,
},
});

const flatTableSchema = defineBlockSchema({
flavour: 'flat-table',
props: () => ({
cols: {} as Record<string, { color: string }>,
rows: [] as Array<{ color: string }>,
}),
metadata: {
role: 'content',
version: 1,
},
});
type RootModel = SchemaToModel<typeof pageSchema>;
type TableModel = SchemaToModel<typeof tableSchema>;
type FlatTableModel = SchemaToModel<typeof flatTableSchema>;

function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
const schema = new Schema();
schema.register([pageSchema, tableSchema]);
schema.register([pageSchema, tableSchema, flatTableSchema]);
return { id: 'test-collection', idGenerator, schema };
}

Expand Down Expand Up @@ -361,3 +375,27 @@ test('deep sync', () => {
expect(onPropsUpdated).toHaveBeenCalledTimes(1);
expect(onRowsUpdated).toHaveBeenCalledTimes(1);
});

test('flat', () => {
const doc = createTestDoc();
const yDoc = new Y.Doc();
const yBlock = yDoc.getMap('yBlock') as YBlock;
yBlock.set('sys:id', '0');
yBlock.set('sys:flavour', 'flat-table');
yBlock.set('sys:children', new Y.Array());

const block = new Block(doc.schema, yBlock, doc);
const model = block.model as FlatTableModel;

model.cols = {
a: { color: 'red' },
};
expect(yBlock.get('prop:cols:a:color')).toBe('red');

Check failure on line 393 in blocksuite/framework/store/src/__tests__/block.unit.spec.ts

View workflow job for this annotation

GitHub Actions / Unit Test (3)

src/__tests__/block.unit.spec.ts > flat

AssertionError: expected undefined to be 'red' // Object.is equality - Expected: "red" + Received: undefined ❯ src/__tests__/block.unit.spec.ts:393:43

model.cols.b = { color: 'blue' };
expect(yBlock.get('prop:cols:b:color')).toBe('blue');
expect(model.cols$.peek()).toEqual({
a: { color: 'red' },
b: { color: 'blue' },
});
});
91 changes: 91 additions & 0 deletions blocksuite/framework/store/src/__tests__/native-y.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from 'vitest';
import * as Y from 'yjs';

import { Text } from '../reactive';
import { flatNative2Y, flatY2Native } from '../reactive/flat-native-y';

describe('flat', () => {
describe('y to native', () => {
test('shallow y map', () => {
const yDoc = new Y.Doc();
const yMap = yDoc.getMap('test');
yMap.set('a', 1);
yMap.set('b', 2);
yMap.set('c', 3);
const native = flatY2Native(yMap);
expect(native).toEqual({ a: 1, b: 2, c: 3 });
});

test('deep y map', () => {
const yDoc = new Y.Doc();
const yMap = yDoc.getMap('test');
yMap.set('a.b.c', 1);
yMap.set('a.b.d', 2);
yMap.set('a.e', 3);
yMap.set('f', 4);
const children = new Y.Array();
children.insert(0, ['a', 'b', 'c']);
const text = new Y.Text();
text.insert(0, 'test');
yMap.set('children', children);
yMap.set('text', text);
const native = flatY2Native(yMap);
expect(native).toEqual({
a: { b: { c: 1, d: 2 }, e: 3 },
f: 4,
children: ['a', 'b', 'c'],
text: new Text(text),
});
});

test('with number key', () => {
const yDoc = new Y.Doc();
const yMap = yDoc.getMap('test');
yMap.set('a.b.1', 1);
yMap.set('a.b.2', 2);
const native = flatY2Native(yMap);
expect(native).toEqual({ a: { b: { 1: 1, 2: 2 } } });
});
});

describe('native to y', () => {
test('shallow native', async () => {
const yDoc = new Y.Doc();
const temp = yDoc.getMap('temp');
const native = { a: 1, b: 2, c: 3 };
const yMap = flatNative2Y(native);
temp.set('test', yMap);

expect(yMap.get('a')).toBe(1);
expect(yMap.get('b')).toBe(2);
expect(yMap.get('c')).toBe(3);
});

test('deep native', async () => {
const yDoc = new Y.Doc();
const temp = yDoc.getMap('temp');
const native = { a: { b: { c: 1, d: 2 }, e: 3 }, f: 4 };
const yMap = flatNative2Y(native);
temp.set('test', yMap);

expect(yMap.get('a.b.c')).toBe(1);
expect(yMap.get('a.b.d')).toBe(2);
expect(yMap.get('a.e')).toBe(3);
expect(yMap.get('f')).toBe(4);
});

test('with array', () => {
const yDoc = new Y.Doc();
const temp = yDoc.getMap('temp');
const native = { a: { b: ['1', '2'] } };
const yMap = flatNative2Y(native);
temp.set('test', yMap);

expect(yMap.get('a.b')).toBeInstanceOf(Y.Array);
expect((yMap.get('a.b') as Y.Array<string>).toArray()).toEqual([
'1',
'2',
]);
});
});
});
4 changes: 4 additions & 0 deletions blocksuite/framework/store/src/model/block/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const BlockSchema = z.object({
flavour: FlavourSchema,
parent: ParentSchema,
children: ContentSchema,
isFlatData: z.boolean().optional(),
props: z
.function()
.args(z.custom<InternalPrimitives>())
Expand Down Expand Up @@ -71,6 +72,7 @@ export function defineBlockSchema<
role: Role;
parent?: string[];
children?: string[];
isFlatData?: boolean;
}>,
Model extends BlockModel<Props>,
Transformer extends BaseBlockTransformer<Props>,
Expand Down Expand Up @@ -102,6 +104,7 @@ export function defineBlockSchema({
role: RoleType;
parent?: string[];
children?: string[];
isFlatData?: boolean;
};
props?: (internalPrimitives: InternalPrimitives) => Record<string, unknown>;
toModel?: () => BlockModel;
Expand All @@ -116,6 +119,7 @@ export function defineBlockSchema({
flavour,
props,
toModel,
isFlatData: metadata.isFlatData,
},
transformer,
} satisfies z.infer<typeof BlockSchema>;
Expand Down
64 changes: 64 additions & 0 deletions blocksuite/framework/store/src/reactive/base-reactive-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Doc as YDoc, YEvent } from 'yjs';
import { UndoManager } from 'yjs';

import type { ProxyOptions } from './proxy';

export abstract class BaseReactiveYData<T, Y> {
protected _getOrigin = (
doc: YDoc
): {
doc: YDoc;
proxy: true;

target: BaseReactiveYData<any, any>;
} => {
return {
doc,
proxy: true,
target: this,
};
};

protected _onObserve = (event: YEvent<any>, handler: () => void) => {
if (
event.transaction.origin?.proxy !== true &&
(!event.transaction.local ||
event.transaction.origin instanceof UndoManager)
) {
handler();
}

this._options.onChange?.(this._proxy);
};

protected abstract readonly _options: ProxyOptions<T>;

protected abstract readonly _proxy: T;

protected _skipNext = false;

protected abstract readonly _source: T;

protected readonly _stashed = new Set<string | number>();

protected _transact = (doc: YDoc, fn: () => void) => {
doc.transact(fn, this._getOrigin(doc));
};

protected _updateWithSkip = (fn: () => void) => {
this._skipNext = true;
fn();
this._skipNext = false;
};

protected abstract readonly _ySource: Y;

get proxy() {
return this._proxy;
}

protected abstract _getProxy(): T;

abstract pop(prop: string | number): void;
abstract stash(prop: string | number): void;
}
83 changes: 83 additions & 0 deletions blocksuite/framework/store/src/reactive/flat-native-y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Array as YArray, Map as YMap, Text as YText } from 'yjs';

import { Boxed } from './boxed';
import { isPureObject } from './is-pure-object';
import { Text } from './text';
import type { TransformOptions } from './types';

export function flatNative2Y(
value: unknown,
{ transform = x => x }: TransformOptions = {}
) {
if (!isPureObject(value)) {
throw new Error('flatNative2Y only support object to Y.Map');
}
const yMap = new YMap<unknown>();
const walkThrough = (target: unknown, path: string) => {
if (value instanceof Boxed) {
yMap.set(path, transform(value.yMap, value));
return;
}
if (target instanceof Text) {
const yText = target.yText.doc ? target.yText.clone() : target.yText;
yMap.set(path, transform(yText, target));
return;
}
if (Array.isArray(target)) {
const yArray = YArray.from(target);
yMap.set(path, transform(yArray, target));
return;
}
if (isPureObject(target)) {
Object.entries(target).forEach(([key, value]) => {
walkThrough(value, path.length ? `${path}.${key}` : key);
});
return;
}
yMap.set(path, transform(target, target));
};

walkThrough(value, '');

return yMap;
}

export function flatY2Native(
yAbstract: unknown,
{ transform = x => x }: TransformOptions = {}
) {
if (!(yAbstract instanceof YMap)) {
throw new Error('flatY2Native only support Y.Map to object');
}
const data: Record<string, unknown> = {};
Array.from(yAbstract.entries()).forEach(([key, value]) => {
let finalData = value;
if (Boxed.is(value)) {
finalData = transform(new Boxed(value), value);
} else if (value instanceof YArray) {
finalData = transform(value.toArray(), value);
} else if (value instanceof YText) {
finalData = transform(new Text(value), 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(value, value);
}
const keys = key.split('.');
void keys.reduce((acc: Record<string, unknown>, key, index) => {
if (!acc[key]) {
acc[key] = {};
}
if (index === keys.length - 1) {
acc[key] = finalData;
}
return acc[key] as Record<string, unknown>;
}, data);
});

return data;
}
3 changes: 2 additions & 1 deletion blocksuite/framework/store/src/reactive/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './boxed.js';
export * from './is-pure-object.js';
export * from './native-y.js';
export * from './proxy.js';
export * from './text.js';
export * from './utils.js';
8 changes: 8 additions & 0 deletions blocksuite/framework/store/src/reactive/is-pure-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function isPureObject(value: unknown): value is object {
return (
value !== null &&
typeof value === 'object' &&
Object.prototype.toString.call(value) === '[object Object]' &&
[Object, undefined, null].some(x => x === value.constructor)
);
}
Loading

0 comments on commit ac0f05a

Please sign in to comment.