diff --git a/packages/frontend/core/src/modules/editor-settting/__test__/editor-setting.spec.ts b/packages/frontend/core/src/modules/editor-settting/__test__/editor-setting.spec.ts new file mode 100644 index 0000000000000..e3a3d4c62c2ad --- /dev/null +++ b/packages/frontend/core/src/modules/editor-settting/__test__/editor-setting.spec.ts @@ -0,0 +1,72 @@ +import { Framework, GlobalState, MemoryMemento } from '@toeverything/infra'; +import { expect, test } from 'vitest'; + +import { unflattenObject } from '../../../utils/flatten-object'; +import { EditorSetting } from '../entities/editor-setting'; +import { GlobalStateEditorSettingProvider } from '../impls/global-state'; +import { EditorSettingProvider } from '../provider/editor-setting-provider'; +import { EditorSettingService } from '../services/editor-setting'; + +test('editor setting service', () => { + const framework = new Framework(); + + framework + .service(EditorSettingService) + .entity(EditorSetting, [EditorSettingProvider]) + .impl(EditorSettingProvider, GlobalStateEditorSettingProvider, [ + GlobalState, + ]) + .impl(GlobalState, MemoryMemento); + + const provider = framework.provider(); + + const editorSettingService = provider.get(EditorSettingService); + + // default value + expect(editorSettingService.editorSetting.settings$.value).toMatchObject({ + fontFamily: 'Sans', + 'connector.stroke': '#000000', + }); + + editorSettingService.editorSetting.set('fontFamily', 'Serif'); + expect(editorSettingService.editorSetting.settings$.value).toMatchObject({ + fontFamily: 'Serif', + }); + + // nested object, should be serialized + editorSettingService.editorSetting.set('connector.stroke', { + dark: '#000000', + light: '#ffffff', + }); + expect( + ( + editorSettingService.editorSetting + .provider as GlobalStateEditorSettingProvider + ).get('connector.stroke') + ).toBe('{"dark":"#000000","light":"#ffffff"}'); + + // invalid font family + editorSettingService.editorSetting.provider.set( + 'fontFamily', + JSON.stringify('abc') + ); + + // should fallback to default value + expect(editorSettingService.editorSetting.settings$.value['fontFamily']).toBe( + 'Sans' + ); + + // expend demo + const expended = unflattenObject( + editorSettingService.editorSetting.settings$.value + ); + expect(expended).toMatchObject({ + fontFamily: 'Sans', + connector: { + stroke: { + dark: '#000000', + light: '#ffffff', + }, + }, + }); +}); diff --git a/packages/frontend/core/src/modules/editor-settting/entities/editor-setting.ts b/packages/frontend/core/src/modules/editor-settting/entities/editor-setting.ts new file mode 100644 index 0000000000000..cb29c97531bbe --- /dev/null +++ b/packages/frontend/core/src/modules/editor-settting/entities/editor-setting.ts @@ -0,0 +1,43 @@ +import { Entity, LiveData } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { EditorSettingProvider } from '../provider/editor-setting-provider'; +import { EditorSettingSchema } from '../schema'; + +export class EditorSetting extends Entity { + constructor(public readonly provider: EditorSettingProvider) { + super(); + } + + settings$ = LiveData.from(this.watchAll(), null as any); + + set( + key: K, + value: EditorSettingSchema[K] + ) { + const schema = EditorSettingSchema.shape[key]; + + this.provider.set(key, JSON.stringify(schema.parse(value))); + } + + private watchAll(): Observable { + return this.provider.watchAll().pipe( + map( + all => + Object.fromEntries( + Object.entries(EditorSettingSchema.shape).map(([key, schema]) => { + const value = all[key]; + const parsed = schema.safeParse( + value ? JSON.parse(value) : undefined + ); + return [ + key, + // if parsing fails, return the default value + parsed.success ? parsed.data : schema.parse(undefined), + ]; + }) + ) as EditorSettingSchema + ) + ); + } +} diff --git a/packages/frontend/core/src/modules/editor-settting/impls/global-state.ts b/packages/frontend/core/src/modules/editor-settting/impls/global-state.ts new file mode 100644 index 0000000000000..346aca144a93b --- /dev/null +++ b/packages/frontend/core/src/modules/editor-settting/impls/global-state.ts @@ -0,0 +1,35 @@ +import type { GlobalState } from '@toeverything/infra'; +import { Service } from '@toeverything/infra'; +import { map, type Observable } from 'rxjs'; + +import type { EditorSettingProvider } from '../provider/editor-setting-provider'; + +const storageKey = 'editor-setting'; + +/** + * just for testing, vary poor performance + */ +export class GlobalStateEditorSettingProvider + extends Service + implements EditorSettingProvider +{ + constructor(public readonly globalState: GlobalState) { + super(); + } + set(key: string, value: string): void { + const all = this.globalState.get>(storageKey) ?? {}; + const after = { + ...all, + [key]: value, + }; + this.globalState.set(storageKey, after); + } + get(key: string): string | undefined { + return this.globalState.get>(storageKey)?.[key]; + } + watchAll(): Observable> { + return this.globalState + .watch>(storageKey) + .pipe(map(all => all ?? {})); + } +} diff --git a/packages/frontend/core/src/modules/editor-settting/index.ts b/packages/frontend/core/src/modules/editor-settting/index.ts new file mode 100644 index 0000000000000..a56395c75e87b --- /dev/null +++ b/packages/frontend/core/src/modules/editor-settting/index.ts @@ -0,0 +1,15 @@ +import { type Framework, GlobalState } from '@toeverything/infra'; + +import { EditorSetting } from './entities/editor-setting'; +import { GlobalStateEditorSettingProvider } from './impls/global-state'; +import { EditorSettingProvider } from './provider/editor-setting-provider'; +import { EditorSettingService } from './services/editor-setting'; + +export function configureEditorSettingModule(framework: Framework) { + framework + .service(EditorSettingService) + .entity(EditorSetting, [EditorSettingProvider]) + .impl(EditorSettingProvider, GlobalStateEditorSettingProvider, [ + GlobalState, + ]); +} diff --git a/packages/frontend/core/src/modules/editor-settting/provider/editor-setting-provider.ts b/packages/frontend/core/src/modules/editor-settting/provider/editor-setting-provider.ts new file mode 100644 index 0000000000000..bd6f8e3cf32cb --- /dev/null +++ b/packages/frontend/core/src/modules/editor-settting/provider/editor-setting-provider.ts @@ -0,0 +1,11 @@ +import { createIdentifier } from '@toeverything/infra'; +import type { Observable } from 'rxjs'; + +export interface EditorSettingProvider { + set(key: string, value: string): void; + watchAll(): Observable>; +} + +export const EditorSettingProvider = createIdentifier( + 'EditorSettingProvider' +); diff --git a/packages/frontend/core/src/modules/editor-settting/schema.ts b/packages/frontend/core/src/modules/editor-settting/schema.ts new file mode 100644 index 0000000000000..a47bc4013eed3 --- /dev/null +++ b/packages/frontend/core/src/modules/editor-settting/schema.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; + +const BSEditorSettingSchema = z.object({ + // TODO: import from bs + connector: z.object({ + stroke: z + .union([ + z.string(), + z.object({ + dark: z.string(), + light: z.string(), + }), + ]) + .default('#000000'), + }), +}); + +const AffineEditorSettingSchema = z.object({ + fontFamily: z.enum(['Sans', 'Serif', 'Mono', 'Custom']).default('Sans'), +}); + +type UnionToIntersection = (U extends any ? (x: U) => void : never) extends ( + x: infer I +) => void + ? I + : never; + +type FlattenZodObject = + O extends z.ZodObject + ? { + [A in keyof T]: T[A] extends z.ZodObject + ? A extends string + ? FlattenZodObject + : never + : A extends string + ? { [key in `${Prefix}${A}`]: T[A] } + : never; + }[keyof T] + : never; + +function flattenZodObject>( + schema: S, + target: z.ZodObject = z.object({}), + prefix = '' +) { + for (const key in schema.shape) { + const value = schema.shape[key]; + if (value instanceof z.ZodObject) { + flattenZodObject(value, target, prefix + key + '.'); + } else { + target.shape[prefix + key] = value; + } + } + type Result = UnionToIntersection>; + return target as Result extends z.ZodRawShape ? z.ZodObject : never; +} + +export const EditorSettingSchema = flattenZodObject( + BSEditorSettingSchema.merge(AffineEditorSettingSchema) +); + +export type EditorSettingSchema = z.infer; diff --git a/packages/frontend/core/src/modules/editor-settting/services/editor-setting.ts b/packages/frontend/core/src/modules/editor-settting/services/editor-setting.ts new file mode 100644 index 0000000000000..2f3b391c1bee0 --- /dev/null +++ b/packages/frontend/core/src/modules/editor-settting/services/editor-setting.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { EditorSetting } from '../entities/editor-setting'; + +export class EditorSettingService extends Service { + editorSetting = this.framework.createEntity(EditorSetting); +} diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 3cc3710f80d86..63b017261ffd1 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -6,6 +6,7 @@ import { configureCollectionModule } from './collection'; import { configureDocLinksModule } from './doc-link'; import { configureDocsSearchModule } from './docs-search'; import { configureEditorModule } from './editor'; +import { configureEditorSettingModule } from './editor-settting'; import { configureExplorerModule } from './explorer'; import { configureFavoriteModule } from './favorite'; import { configureFindInPageModule } from './find-in-page'; @@ -43,4 +44,5 @@ export function configureCommonModules(framework: Framework) { configureThemeEditorModule(framework); configureEditorModule(framework); configureSystemFontFamilyModule(framework); + configureEditorSettingModule(framework); } diff --git a/packages/frontend/core/src/utils/__tests__/flatten-object.spec.ts b/packages/frontend/core/src/utils/__tests__/flatten-object.spec.ts new file mode 100644 index 0000000000000..c4d06ba64e825 --- /dev/null +++ b/packages/frontend/core/src/utils/__tests__/flatten-object.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'vitest'; + +import { unflattenObject } from '../flatten-object'; + +test('unflattenObject', () => { + const ob = { + 'a.b.c': 1, + d: 2, + }; + const result = unflattenObject(ob); + expect(result).toEqual({ + a: { + b: { + c: 1, + }, + }, + d: 2, + }); +}); diff --git a/packages/frontend/core/src/utils/flatten-object.ts b/packages/frontend/core/src/utils/flatten-object.ts new file mode 100644 index 0000000000000..7fd64b82f1943 --- /dev/null +++ b/packages/frontend/core/src/utils/flatten-object.ts @@ -0,0 +1,22 @@ +export function unflattenObject(ob: any) { + const result: any = {}; + + for (const key in ob) { + if (!Object.prototype.hasOwnProperty.call(ob, key)) continue; + + const keys = key.split('.'); + let current = result; + + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (i === keys.length - 1) { + current[k] = ob[key]; + } else { + current[k] = current[k] || {}; + current = current[k]; + } + } + } + + return result; +} diff --git a/packages/frontend/core/src/utils/index.ts b/packages/frontend/core/src/utils/index.ts index 01feef0741ac6..6137aaa5b405a 100644 --- a/packages/frontend/core/src/utils/index.ts +++ b/packages/frontend/core/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './create-emotion-cache'; export * from './event'; export * from './extract-emoji-icon'; +export * from './flatten-object'; export * from './fractional-indexing'; export * from './popup'; export * from './string2color';