diff --git a/packages/common/infra/src/modules/db/index.ts b/packages/common/infra/src/modules/db/index.ts index ffc62aa93d8e8..065e916788061 100644 --- a/packages/common/infra/src/modules/db/index.ts +++ b/packages/common/infra/src/modules/db/index.ts @@ -4,7 +4,7 @@ import { WorkspaceDB } from './entities/db'; import { WorkspaceDBTable } from './entities/table'; import { WorkspaceDBService } from './services/db'; -export { AFFiNE_WORKSPACE_DB_SCHEMA } from './schema'; +export type { DocProperties } from './schema'; export { WorkspaceDBService } from './services/db'; export { transformWorkspaceDBLocalToCloud } from './services/db'; diff --git a/packages/common/infra/src/modules/db/schema/index.ts b/packages/common/infra/src/modules/db/schema/index.ts index d56a956d854f9..f8c9cd92e46e0 100644 --- a/packages/common/infra/src/modules/db/schema/index.ts +++ b/packages/common/infra/src/modules/db/schema/index.ts @@ -1 +1,5 @@ -export { AFFiNE_WORKSPACE_DB_SCHEMA } from './schema'; +export type { DocProperties } from './schema'; +export { + AFFiNE_WORKSPACE_DB_SCHEMA, + AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, +} from './schema'; diff --git a/packages/common/infra/src/modules/db/schema/schema.ts b/packages/common/infra/src/modules/db/schema/schema.ts index ff481d880218b..a3ec45e3b4707 100644 --- a/packages/common/infra/src/modules/db/schema/schema.ts +++ b/packages/common/infra/src/modules/db/schema/schema.ts @@ -1,6 +1,6 @@ import { nanoid } from 'nanoid'; -import { type DBSchemaBuilder, f } from '../../../orm'; +import { type DBSchemaBuilder, f, type ORMEntity, t } from '../../../orm'; export const AFFiNE_WORKSPACE_DB_SCHEMA = { folders: { @@ -10,9 +10,34 @@ export const AFFiNE_WORKSPACE_DB_SCHEMA = { type: f.string(), index: f.string(), }, + docProperties: t.document({ + // { [`custom:{customPropertyId}`]: any } + id: f.string().primaryKey(), + primaryMode: f.string().optional(), + edgelessColorTheme: f.string().optional(), + journal: f.string().optional(), + }), + docCustomPropertyInfo: { + id: f.string().primaryKey().optional().default(nanoid), + name: f.string().optional(), + type: f.string(), + show: f.string().optional(), + index: f.string().optional(), + additionalData: f.json().optional(), + isDeleted: f.boolean().optional(), + // we will keep deleted properties in the database, for override legacy data + }, } as const satisfies DBSchemaBuilder; export type AFFiNE_WORKSPACE_DB_SCHEMA = typeof AFFiNE_WORKSPACE_DB_SCHEMA; +export type DocProperties = ORMEntity< + AFFiNE_WORKSPACE_DB_SCHEMA['docProperties'] +>; + +export type DocCustomPropertyInfo = ORMEntity< + AFFiNE_WORKSPACE_DB_SCHEMA['docCustomPropertyInfo'] +>; + export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = { favorite: { key: f.string().primaryKey(), diff --git a/packages/common/infra/src/modules/db/services/db.ts b/packages/common/infra/src/modules/db/services/db.ts index 8d41bc998d208..40bc7a68f046d 100644 --- a/packages/common/infra/src/modules/db/services/db.ts +++ b/packages/common/infra/src/modules/db/services/db.ts @@ -6,8 +6,10 @@ import type { DocStorage } from '../../../sync'; import { ObjectPool } from '../../../utils'; import type { WorkspaceService } from '../../workspace'; import { WorkspaceDB, type WorkspaceDBWithTables } from '../entities/db'; -import { AFFiNE_WORKSPACE_DB_SCHEMA } from '../schema'; -import { AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA } from '../schema/schema'; +import { + AFFiNE_WORKSPACE_DB_SCHEMA, + AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, +} from '../schema'; const WorkspaceDBClient = createORMClient(AFFiNE_WORKSPACE_DB_SCHEMA); const WorkspaceUserdataDBClient = createORMClient( diff --git a/packages/common/infra/src/modules/doc/entities/doc.ts b/packages/common/infra/src/modules/doc/entities/doc.ts index f08af61a6a427..d7dff5067e708 100644 --- a/packages/common/infra/src/modules/doc/entities/doc.ts +++ b/packages/common/infra/src/modules/doc/entities/doc.ts @@ -29,6 +29,7 @@ export class Doc extends Entity { public readonly record = this.scope.props.record; readonly meta$ = this.record.meta$; + readonly properties$ = this.record.properties$; readonly primaryMode$ = this.record.primaryMode$; readonly title$ = this.record.title$; readonly trash$ = this.record.trash$; diff --git a/packages/common/infra/src/modules/doc/entities/property-list.ts b/packages/common/infra/src/modules/doc/entities/property-list.ts new file mode 100644 index 0000000000000..ba11540d7d14a --- /dev/null +++ b/packages/common/infra/src/modules/doc/entities/property-list.ts @@ -0,0 +1,27 @@ +import { Entity } from '../../../framework'; +import { LiveData } from '../../../livedata'; +import type { DocCustomPropertyInfo } from '../../db/schema/schema'; +import type { DocPropertiesStore } from '../stores/doc-properties'; + +export class DocPropertyList extends Entity { + constructor(private readonly docPropertiesStore: DocPropertiesStore) { + super(); + } + + properties$ = LiveData.from( + this.docPropertiesStore.watchDocPropertyInfoList(), + [] + ); + + updatePropertyInfo(id: string, properties: Partial) { + this.docPropertiesStore.updateDocPropertyInfo(id, properties); + } + + createProperty(properties: DocCustomPropertyInfo) { + return this.docPropertiesStore.createDocPropertyInfo(properties); + } + + removeProperty(id: string) { + this.docPropertiesStore.removeDocPropertyInfo(id); + } +} diff --git a/packages/common/infra/src/modules/doc/entities/record.ts b/packages/common/infra/src/modules/doc/entities/record.ts index 6afe1876d1ddb..3c4e6dfc7fff0 100644 --- a/packages/common/infra/src/modules/doc/entities/record.ts +++ b/packages/common/infra/src/modules/doc/entities/record.ts @@ -3,6 +3,8 @@ import type { DocMeta } from '@blocksuite/affine/store'; import { Entity } from '../../../framework'; import { LiveData } from '../../../livedata'; +import type { DocProperties } from '../../db'; +import type { DocPropertiesStore } from '../stores/doc-properties'; import type { DocsStore } from '../stores/docs'; /** @@ -12,7 +14,10 @@ import type { DocsStore } from '../stores/docs'; */ export class DocRecord extends Entity<{ id: string }> { id: string = this.props.id; - constructor(private readonly docsStore: DocsStore) { + constructor( + private readonly docsStore: DocsStore, + private readonly docPropertiesStore: DocPropertiesStore + ) { super(); } @@ -21,6 +26,15 @@ export class DocRecord extends Entity<{ id: string }> { {} ); + properties$ = LiveData.from( + this.docPropertiesStore.watchDocProperties(this.id), + { id: this.id } + ); + + setProperties(properties: Partial): void { + this.docPropertiesStore.updateDocProperties(this.id, properties); + } + setMeta(meta: Partial): void { this.docsStore.setDocMeta(this.id, meta); } diff --git a/packages/common/infra/src/modules/doc/index.ts b/packages/common/infra/src/modules/doc/index.ts index ca03973c08f97..70fce26c3a8ac 100644 --- a/packages/common/infra/src/modules/doc/index.ts +++ b/packages/common/infra/src/modules/doc/index.ts @@ -6,26 +6,27 @@ export { DocService } from './services/doc'; export { DocsService } from './services/docs'; import type { Framework } from '../../framework'; -import { - WorkspaceLocalState, - WorkspaceScope, - WorkspaceService, -} from '../workspace'; +import { WorkspaceDBService } from '../db'; +import { WorkspaceScope, WorkspaceService } from '../workspace'; import { Doc } from './entities/doc'; +import { DocPropertyList } from './entities/property-list'; import { DocRecord } from './entities/record'; import { DocRecordList } from './entities/record-list'; import { DocScope } from './scopes/doc'; import { DocService } from './services/doc'; import { DocsService } from './services/docs'; +import { DocPropertiesStore } from './stores/doc-properties'; import { DocsStore } from './stores/docs'; export function configureDocModule(framework: Framework) { framework .scope(WorkspaceScope) .service(DocsService, [DocsStore]) - .store(DocsStore, [WorkspaceService, WorkspaceLocalState]) - .entity(DocRecord, [DocsStore]) + .store(DocPropertiesStore, [WorkspaceService, WorkspaceDBService]) + .store(DocsStore, [WorkspaceService, DocPropertiesStore]) + .entity(DocRecord, [DocsStore, DocPropertiesStore]) .entity(DocRecordList, [DocsStore]) + .entity(DocPropertyList, [DocPropertiesStore]) .scope(DocScope) .entity(Doc, [DocScope, DocsStore, WorkspaceService]) .service(DocService); diff --git a/packages/common/infra/src/modules/doc/services/docs.ts b/packages/common/infra/src/modules/doc/services/docs.ts index 5485301b3a900..352fd37913c27 100644 --- a/packages/common/infra/src/modules/doc/services/docs.ts +++ b/packages/common/infra/src/modules/doc/services/docs.ts @@ -5,6 +5,7 @@ import { Service } from '../../../framework'; import { type DocProps, initDocFromProps } from '../../../initialization'; import { ObjectPool } from '../../../utils'; import type { Doc } from '../entities/doc'; +import { DocPropertyList } from '../entities/property-list'; import { DocRecordList } from '../entities/record-list'; import { DocScope } from '../scopes/doc'; import type { DocsStore } from '../stores/docs'; @@ -19,6 +20,8 @@ export class DocsService extends Service { }, }); + propertyList = this.framework.createEntity(DocPropertyList); + constructor(private readonly store: DocsStore) { super(); } diff --git a/packages/common/infra/src/modules/doc/stores/doc-properties.ts b/packages/common/infra/src/modules/doc/stores/doc-properties.ts new file mode 100644 index 0000000000000..6f3b77db636b7 --- /dev/null +++ b/packages/common/infra/src/modules/doc/stores/doc-properties.ts @@ -0,0 +1,232 @@ +import { differenceBy, isNil, omitBy } from 'lodash-es'; +import { combineLatest, map, switchMap } from 'rxjs'; +import { AbstractType as YAbstractType } from 'yjs'; + +import { Store } from '../../../framework'; +import { + yjsObserveByPath, + yjsObserveDeep, +} from '../../../utils/yjs-observable'; +import type { WorkspaceDBService } from '../../db'; +import type { + DocCustomPropertyInfo, + DocProperties, +} from '../../db/schema/schema'; +import type { WorkspaceService } from '../../workspace'; + +interface LegacyDocProperties { + custom?: Record; + system?: Record; +} + +type LegacyDocPropertyInfo = { + id?: string; + name?: string; + type?: string; +}; + +type LegacyDocPropertyInfoList = Record< + string, + LegacyDocPropertyInfo | undefined +>; + +export class DocPropertiesStore extends Store { + constructor( + private readonly workspaceService: WorkspaceService, + private readonly dbService: WorkspaceDBService + ) { + super(); + } + + updateDocProperties(id: string, config: Partial) { + return this.dbService.db.docProperties.create({ + id, + ...config, + }); + } + + getDocPropertyInfoList() { + const db = this.dbService.db.docCustomPropertyInfo.find(); + const legacy = this.upgradeLegacyDocPropertyInfoList( + this.getLegacyDocPropertyInfoList() + ); + const notOverridden = differenceBy(legacy, db, i => i.id); + return [...db, ...notOverridden].filter(i => !i.isDeleted); + } + + createDocPropertyInfo(config: DocCustomPropertyInfo) { + return this.dbService.db.docCustomPropertyInfo.create(config).id; + } + + removeDocPropertyInfo(id: string) { + this.updateDocPropertyInfo(id, { + additionalData: {}, // also remove additional data to reduce size + isDeleted: true, + }); + } + + updateDocPropertyInfo(id: string, config: Partial) { + const needMigration = !this.dbService.db.docCustomPropertyInfo.get(id); + if (needMigration) { + // if this property is not in db, we need to migration it from legacy to db, only type and name is needed + this.migrateLegacyDocPropertyInfo(id, config); + } else { + this.dbService.db.docCustomPropertyInfo.update(id, config); + } + } + + migrateLegacyDocPropertyInfo( + id: string, + override: Partial + ) { + const legacy = this.getLegacyDocPropertyInfo(id); + this.dbService.db.docCustomPropertyInfo.create({ + id, + type: + legacy?.type ?? + 'unknown' /* should never reach here, just for safety, we need handle unknown property type */, + name: legacy?.name, + ...override, + }); + } + + watchDocPropertyInfoList() { + return combineLatest([ + this.watchLegacyDocPropertyInfoList().pipe( + map(this.upgradeLegacyDocPropertyInfoList) + ), + this.dbService.db.docCustomPropertyInfo.find$({}), + ]).pipe( + map(([legacy, db]) => { + const notOverridden = differenceBy(legacy, db, i => i.id); + return [...db, ...notOverridden].filter(i => !i.isDeleted); + }) + ); + } + + getDocProperties(id: string) { + return { + ...this.upgradeLegacyDocProperties(this.getLegacyDocProperties(id)), + ...omitBy(this.dbService.db.docProperties.get(id), isNil), + // db always override legacy, but nil value should not override + }; + } + + watchDocProperties(id: string) { + return combineLatest([ + this.watchLegacyDocProperties(id).pipe( + map(this.upgradeLegacyDocProperties) + ), + this.dbService.db.docProperties.get$(id), + ]).pipe( + map( + ([legacy, db]) => + ({ + ...legacy, + ...omitBy(db, isNil), // db always override legacy, but nil value should not override + }) as DocProperties + ) + ); + } + + private upgradeLegacyDocProperties(properties?: LegacyDocProperties) { + if (!properties) { + return {}; + } + const newProperties: Record = {}; + for (const [key, info] of Object.entries(properties.system ?? {})) { + if (info?.value !== undefined) { + newProperties[key] = info.value; + } + } + for (const [key, info] of Object.entries(properties.custom ?? {})) { + if (info?.value !== undefined) { + newProperties['custom:' + key] = info.value; + } + } + return newProperties; + } + + private upgradeLegacyDocPropertyInfoList( + infoList?: LegacyDocPropertyInfoList + ) { + if (!infoList) { + return []; + } + + const newInfoList: DocCustomPropertyInfo[] = []; + + for (const [id, info] of Object.entries(infoList ?? {})) { + if (info?.type) { + newInfoList.push({ + id, + name: info.name, + type: info.type, + }); + } + } + + return newInfoList; + } + + private getLegacyDocProperties(id: string) { + return this.workspaceService.workspace.rootYDoc + .getMap('affine:workspace-properties') + .get('pageProperties') + ?.get(id) + ?.toJSON() as LegacyDocProperties | undefined; + } + + private watchLegacyDocProperties(id: string) { + return yjsObserveByPath( + this.workspaceService.workspace.rootYDoc.getMap( + 'affine:workspace-properties' + ), + `pageProperties.${id}` + ).pipe( + switchMap(yjsObserveDeep), + map( + p => + (p instanceof YAbstractType ? p.toJSON() : p) as + | LegacyDocProperties + | undefined + ) + ); + } + + private getLegacyDocPropertyInfoList() { + return this.workspaceService.workspace.rootYDoc + .getMap('affine:workspace-properties') + .get('schema') + ?.get('pageProperties') + ?.get('custom') + ?.toJSON() as LegacyDocPropertyInfoList | undefined; + } + + private watchLegacyDocPropertyInfoList() { + return yjsObserveByPath( + this.workspaceService.workspace.rootYDoc.getMap( + 'affine:workspace-properties' + ), + 'schema.pageProperties.custom' + ).pipe( + switchMap(yjsObserveDeep), + map( + p => + (p instanceof YAbstractType ? p.toJSON() : p) as + | LegacyDocPropertyInfoList + | undefined + ) + ); + } + + private getLegacyDocPropertyInfo(id: string) { + return this.workspaceService.workspace.rootYDoc + .getMap('affine:workspace-properties') + .get('schema') + ?.get('pageProperties') + ?.get('custom') + ?.get(id) + ?.toJSON() as LegacyDocPropertyInfo | undefined; + } +} diff --git a/packages/common/infra/src/modules/doc/stores/docs.ts b/packages/common/infra/src/modules/doc/stores/docs.ts index 0647a0a50e58b..99854744c5b42 100644 --- a/packages/common/infra/src/modules/doc/stores/docs.ts +++ b/packages/common/infra/src/modules/doc/stores/docs.ts @@ -1,15 +1,17 @@ import type { DocMode } from '@blocksuite/affine/blocks'; import type { DocMeta } from '@blocksuite/affine/store'; -import { isEqual } from 'lodash-es'; -import { distinctUntilChanged, Observable } from 'rxjs'; +import { distinctUntilChanged, map, switchMap } from 'rxjs'; +import { Array as YArray, Map as YMap } from 'yjs'; import { Store } from '../../../framework'; -import type { WorkspaceLocalState, WorkspaceService } from '../../workspace'; +import { yjsObserve, yjsObserveByPath, yjsObserveDeep } from '../../../utils'; +import type { WorkspaceService } from '../../workspace'; +import type { DocPropertiesStore } from './doc-properties'; export class DocsStore extends Store { constructor( private readonly workspaceService: WorkspaceService, - private readonly localState: WorkspaceLocalState + private readonly docPropertiesStore: DocPropertiesStore ) { super(); } @@ -23,72 +25,67 @@ export class DocsStore extends Store { } watchDocIds() { - return new Observable(subscriber => { - const emit = () => { - subscriber.next( - this.workspaceService.workspace.docCollection.meta.docMetas.map( - v => v.id - ) - ); - }; - - emit(); - - const dispose = - this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on( - emit - ).dispose; - return () => { - dispose(); - }; - }); + return yjsObserveByPath( + this.workspaceService.workspace.rootYDoc.getMap('meta'), + 'pages' + ).pipe( + switchMap(yjsObserve), + map(meta => { + if (meta instanceof YArray) { + return meta.map(v => v.get('id')); + } else { + return []; + } + }) + ); } watchTrashDocIds() { - return new Observable(subscriber => { - const emit = () => { - subscriber.next( - this.workspaceService.workspace.docCollection.meta.docMetas - .map(v => (v.trash ? v.id : null)) - .filter(Boolean) as string[] - ); - }; - - emit(); - - const dispose = - this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on( - emit - ).dispose; - return () => { - dispose(); - }; - }); + return yjsObserveByPath( + this.workspaceService.workspace.rootYDoc.getMap('meta'), + 'pages' + ).pipe( + switchMap(yjsObserveDeep), + map(meta => { + if (meta instanceof YArray) { + return meta + .map(v => (v.get('trash') ? v.get('id') : null)) + .filter(Boolean) as string[]; + } else { + return []; + } + }) + ); } watchDocMeta(id: string) { - let meta: DocMeta | null = null; - return new Observable>(subscriber => { - const emit = () => { - if (meta === null) { - // getDocMeta is heavy, so we cache the doc meta reference - meta = - this.workspaceService.workspace.docCollection.meta.getDocMeta(id) || - null; + return yjsObserveByPath( + this.workspaceService.workspace.rootYDoc.getMap('meta'), + 'pages' + ).pipe( + switchMap(yjsObserve), + map(meta => { + if (meta instanceof YArray) { + let docMetaYMap = null as YMap | null; + meta.forEach(doc => { + if (doc.get('id') === id) { + docMetaYMap = doc; + } + }); + return docMetaYMap; + } else { + return null; } - subscriber.next({ ...meta }); - }; - - emit(); - - const dispose = - this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on( - emit - ).dispose; - return () => { - dispose(); - }; - }).pipe(distinctUntilChanged((p, c) => isEqual(p, c))); + }), + switchMap(yjsObserveDeep), + map(meta => { + if (meta instanceof YMap) { + return meta.toJSON() as Partial; + } else { + return {}; + } + }) + ); } watchDocListReady() { @@ -102,15 +99,20 @@ export class DocsStore extends Store { } setDocPrimaryModeSetting(id: string, mode: DocMode) { - return this.localState.set(`page:${id}:mode`, mode); + return this.docPropertiesStore.updateDocProperties(id, { + primaryMode: mode, + }); } getDocPrimaryModeSetting(id: string) { - return this.localState.get(`page:${id}:mode`); + return this.docPropertiesStore.getDocProperties(id)?.primaryMode; } watchDocPrimaryModeSetting(id: string) { - return this.localState.watch(`page:${id}:mode`); + return this.docPropertiesStore.watchDocProperties(id).pipe( + map(config => config?.primaryMode), + distinctUntilChanged((p, c) => p === c) + ); } waitForDocLoadReady(id: string) { diff --git a/packages/common/infra/src/modules/workspace/entities/workspace.ts b/packages/common/infra/src/modules/workspace/entities/workspace.ts index bf61729b95254..4150389b1c4b2 100644 --- a/packages/common/infra/src/modules/workspace/entities/workspace.ts +++ b/packages/common/infra/src/modules/workspace/entities/workspace.ts @@ -5,6 +5,7 @@ import type { Awareness } from 'y-protocols/awareness.js'; import { Entity } from '../../../framework'; import { LiveData } from '../../../livedata'; +import { WorkspaceDBService } from '../../db'; import { getAFFiNEWorkspaceSchema } from '../global-schema'; import type { WorkspaceScope } from '../scopes/workspace'; import { WorkspaceEngineService } from '../services/engine'; @@ -42,6 +43,10 @@ export class Workspace extends Entity { return this._docCollection; } + get db() { + return this.framework.get(WorkspaceDBService).db; + } + get awareness() { return this.docCollection.awarenessStore.awareness as Awareness; } diff --git a/packages/common/infra/src/orm/index.ts b/packages/common/infra/src/orm/index.ts index 04e5e62a2d451..82b1f18777d29 100644 --- a/packages/common/infra/src/orm/index.ts +++ b/packages/common/infra/src/orm/index.ts @@ -2,8 +2,10 @@ export type { DBSchemaBuilder, FieldSchemaBuilder, ORMClient, + Entity as ORMEntity, Table, TableMap, TableSchemaBuilder, + UpdateEntityInput, } from './core'; -export { createORMClient, f, YjsDBAdapter } from './core'; +export { createORMClient, f, t, YjsDBAdapter } from './core'; diff --git a/packages/common/infra/src/utils/__tests__/yjs-observable.spec.ts b/packages/common/infra/src/utils/__tests__/yjs-observable.spec.ts new file mode 100644 index 0000000000000..c5dabb3c99778 --- /dev/null +++ b/packages/common/infra/src/utils/__tests__/yjs-observable.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; +import { Doc as YDoc, Map as YMap } from 'yjs'; + +import { yjsObserveByPath } from '../yjs-observable'; + +describe('yjs observable', () => { + test('basic', async () => { + const ydoc = new YDoc(); + let currentValue: any = false; + yjsObserveByPath(ydoc.getMap('foo'), 'key.subkey').subscribe( + v => (currentValue = v) + ); + expect(currentValue).toBe(undefined); + + ydoc.getMap('foo').set('key', new YMap([['subkey', 'xxxzzz']])); + expect(currentValue).toBe('xxxzzz'); + + (ydoc.getMap('foo').get('key') as YMap).set('subkey', 'yyy'); + expect(currentValue).toBe('yyy'); + + (ydoc.getMap('foo').get('key') as YMap).delete('subkey'); + expect(currentValue).toBe(undefined); + + (ydoc.getMap('foo').get('key') as YMap).set('subkey', 'yyy'); + ydoc.getMap('foo').delete('key'); + expect(currentValue).toBe(undefined); + + ydoc.getMap('foo').set('key', 'text'); + expect(currentValue).toBe(undefined); + }); +}); diff --git a/packages/common/infra/src/utils/index.ts b/packages/common/infra/src/utils/index.ts index 33aa7e8d17101..ba1d46b39d9c7 100644 --- a/packages/common/infra/src/utils/index.ts +++ b/packages/common/infra/src/utils/index.ts @@ -5,3 +5,4 @@ export * from './merge-updates'; export * from './object-pool'; export * from './stable-hash'; export * from './throw-if-aborted'; +export * from './yjs-observable'; diff --git a/packages/common/infra/src/utils/yjs-observable.ts b/packages/common/infra/src/utils/yjs-observable.ts new file mode 100644 index 0000000000000..bb77439dbe129 --- /dev/null +++ b/packages/common/infra/src/utils/yjs-observable.ts @@ -0,0 +1,121 @@ +import { distinctUntilChanged, Observable, of, switchMap } from 'rxjs'; +import { + AbstractType as YAbstractType, + Array as YArray, + Map as YMap, +} from 'yjs'; + +/** + * + * @param path key.[0].key2.[1] + */ +function parsePath(path: string): (string | number)[] { + const parts = path.split('.'); + return parts.map(part => { + if (part.startsWith('[') && part.endsWith(']')) { + const index = parseInt(part.slice(1, -1), 10); + if (isNaN(index)) { + throw new Error(`index: ${part} is not a number`); + } + return index; + } + return part; + }); +} + +function _yjsDeepWatch( + target: any, + path: ReturnType +): Observable { + if (path.length === 0) { + return of(target); + } + const current = path[0]; + + if (target instanceof YArray || target instanceof YMap) { + return new Observable(subscriber => { + const refresh = () => { + if (typeof current === 'number' && target instanceof YArray) { + subscriber.next(target.get(current)); + } else if (typeof current === 'string' && target instanceof YMap) { + subscriber.next(target.get(current)); + } else { + subscriber.next(undefined); + } + }; + refresh(); + target.observe(refresh); + return () => { + target.unobserve(refresh); + }; + }).pipe( + distinctUntilChanged(), + switchMap(arr => _yjsDeepWatch(arr, path.slice(1))) + ); + } else { + return of(undefined); + } +} + +/** + * extract data from yjs type based on path, and return an observable. + * observable will automatically update when yjs data changed. + * if data is not exist on path, the observable will emit undefined. + * + * this function is optimized for deep watch performance. + * + * @example + * yjsObserveByPath(yjs, 'pages.[0].id') -> only emit when pages[0].id changed + * yjsObserveByPath(yjs, 'pages.[0]').switchMap(yjsObserve) -> emit when any of pages[0] or its children changed + * yjsObserveByPath(yjs, 'pages.[0]').switchMap(yjsObserveDeep) -> emit when pages[0] or any of its deep children changed + */ +export function yjsObserveByPath(yjs: YAbstractType, path: string) { + const parsedPath = parsePath(path); + return _yjsDeepWatch(yjs, parsedPath); +} + +/** + * convert yjs type to observable. + * observable will automatically update when yjs data changed. + * + * @example + * yjsObserveDeep(yjs) -> emit when any of its deep children changed + */ +export function yjsObserveDeep(yjs?: any) { + return new Observable(subscriber => { + const refresh = () => { + subscriber.next(yjs); + }; + refresh(); + if (yjs instanceof YAbstractType) { + yjs.observeDeep(refresh); + return () => { + yjs.unobserveDeep(refresh); + }; + } + return; + }); +} + +/** + * convert yjs type to observable. + * observable will automatically update when yjs data changed. + * + * @example + * yjsObserveDeep(yjs) -> emit when any of children changed + */ +export function yjsObserve(yjs?: any) { + return new Observable(subscriber => { + const refresh = () => { + subscriber.next(yjs); + }; + refresh(); + if (yjs instanceof YAbstractType) { + yjs.observe(refresh); + return () => { + yjs.unobserve(refresh); + }; + } + return; + }); +}