mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
feat(core): editor setting service (#7956)
define editor setting schema in `packages/frontend/core/src/modules/editor-settting/schema.ts`
e.g.
```ts
const BSEditorSettingSchema = z.object({
connector: z.object({
stroke: z
.union([
z.string(),
z.object({
dark: z.string(),
light: z.string(),
}),
])
.default('#000000'), // default is necessary
}),
});
```
schema can be defined in a nested way. EditorSetting api is in flat way:
editorSetting api:
```ts
editorSetting.settings$ === {
'connector.stroke': '#000000'
}
editorSetting.set('connector.stroke', '#000')
```
and use `expandFlattenObject` function can restore the flattened structure to a nested structure. nested structure is required by blocksuite
```ts
editorSetting.settings$.map(expandFlattenObject) === {
connector: {
stroke: '#000000'
}
}
```
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { Framework, GlobalState, MemoryMemento } from '@toeverything/infra';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { unflattenObject } from '../../../utils/unflatten-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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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<EditorSettingSchema>(this.watchAll(), null as any);
|
||||
|
||||
set<K extends keyof EditorSettingSchema>(
|
||||
key: K,
|
||||
value: EditorSettingSchema[K]
|
||||
) {
|
||||
const schema = EditorSettingSchema.shape[key];
|
||||
|
||||
this.provider.set(key, JSON.stringify(schema.parse(value)));
|
||||
}
|
||||
|
||||
private watchAll(): Observable<EditorSettingSchema> {
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, string>>(storageKey) ?? {};
|
||||
const after = {
|
||||
...all,
|
||||
[key]: value,
|
||||
};
|
||||
this.globalState.set(storageKey, after);
|
||||
}
|
||||
get(key: string): string | undefined {
|
||||
return this.globalState.get<Record<string, string>>(storageKey)?.[key];
|
||||
}
|
||||
watchAll(): Observable<Record<string, string>> {
|
||||
return this.globalState
|
||||
.watch<Record<string, string>>(storageKey)
|
||||
.pipe(map(all => all ?? {}));
|
||||
}
|
||||
}
|
||||
15
packages/frontend/core/src/modules/editor-settting/index.ts
Normal file
15
packages/frontend/core/src/modules/editor-settting/index.ts
Normal file
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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<Record<string, string>>;
|
||||
}
|
||||
|
||||
export const EditorSettingProvider = createIdentifier<EditorSettingProvider>(
|
||||
'EditorSettingProvider'
|
||||
);
|
||||
62
packages/frontend/core/src/modules/editor-settting/schema.ts
Normal file
62
packages/frontend/core/src/modules/editor-settting/schema.ts
Normal file
@@ -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> = (U extends any ? (x: U) => void : never) extends (
|
||||
x: infer I
|
||||
) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
type FlattenZodObject<O, Prefix extends string = ''> =
|
||||
O extends z.ZodObject<infer T>
|
||||
? {
|
||||
[A in keyof T]: T[A] extends z.ZodObject<any>
|
||||
? A extends string
|
||||
? FlattenZodObject<T[A], `${Prefix}${A}.`>
|
||||
: never
|
||||
: A extends string
|
||||
? { [key in `${Prefix}${A}`]: T[A] }
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
function flattenZodObject<S extends z.ZodObject<any>>(
|
||||
schema: S,
|
||||
target: z.ZodObject<any> = 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<FlattenZodObject<S>>;
|
||||
return target as Result extends z.ZodRawShape ? z.ZodObject<Result> : never;
|
||||
}
|
||||
|
||||
export const EditorSettingSchema = flattenZodObject(
|
||||
BSEditorSettingSchema.merge(AffineEditorSettingSchema)
|
||||
);
|
||||
|
||||
export type EditorSettingSchema = z.infer<typeof EditorSettingSchema>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { unflattenObject } from '../unflatten-object';
|
||||
|
||||
test('unflattenObject', () => {
|
||||
const ob = {
|
||||
'a.b.c': 1,
|
||||
d: 2,
|
||||
};
|
||||
const result = unflattenObject(ob);
|
||||
expect(result).toEqual({
|
||||
a: {
|
||||
b: {
|
||||
c: 1,
|
||||
},
|
||||
},
|
||||
d: 2,
|
||||
});
|
||||
});
|
||||
@@ -5,3 +5,4 @@ export * from './fractional-indexing';
|
||||
export * from './popup';
|
||||
export * from './string2color';
|
||||
export * from './toast';
|
||||
export * from './unflatten-object';
|
||||
|
||||
22
packages/frontend/core/src/utils/unflatten-object.ts
Normal file
22
packages/frontend/core/src/utils/unflatten-object.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user