Skip to content

Commit bc86f0a

Browse files
committed
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' } } ```
1 parent 3c37006 commit bc86f0a

File tree

11 files changed

+289
-0
lines changed

11 files changed

+289
-0
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Framework, GlobalState, MemoryMemento } from '@toeverything/infra';
2+
import { expect, test } from 'vitest';
3+
4+
import { unflattenObject } from '../../../utils/unflatten-object';
5+
import { EditorSetting } from '../entities/editor-setting';
6+
import { GlobalStateEditorSettingProvider } from '../impls/global-state';
7+
import { EditorSettingProvider } from '../provider/editor-setting-provider';
8+
import { EditorSettingService } from '../services/editor-setting';
9+
10+
test('editor setting service', () => {
11+
const framework = new Framework();
12+
13+
framework
14+
.service(EditorSettingService)
15+
.entity(EditorSetting, [EditorSettingProvider])
16+
.impl(EditorSettingProvider, GlobalStateEditorSettingProvider, [
17+
GlobalState,
18+
])
19+
.impl(GlobalState, MemoryMemento);
20+
21+
const provider = framework.provider();
22+
23+
const editorSettingService = provider.get(EditorSettingService);
24+
25+
// default value
26+
expect(editorSettingService.editorSetting.settings$.value).toMatchObject({
27+
fontFamily: 'Sans',
28+
'connector.stroke': '#000000',
29+
});
30+
31+
editorSettingService.editorSetting.set('fontFamily', 'Serif');
32+
expect(editorSettingService.editorSetting.settings$.value).toMatchObject({
33+
fontFamily: 'Serif',
34+
});
35+
36+
// nested object, should be serialized
37+
editorSettingService.editorSetting.set('connector.stroke', {
38+
dark: '#000000',
39+
light: '#ffffff',
40+
});
41+
expect(
42+
(
43+
editorSettingService.editorSetting
44+
.provider as GlobalStateEditorSettingProvider
45+
).get('connector.stroke')
46+
).toBe('{"dark":"#000000","light":"#ffffff"}');
47+
48+
// invalid font family
49+
editorSettingService.editorSetting.provider.set(
50+
'fontFamily',
51+
JSON.stringify('abc')
52+
);
53+
54+
// should fallback to default value
55+
expect(editorSettingService.editorSetting.settings$.value['fontFamily']).toBe(
56+
'Sans'
57+
);
58+
59+
// expend demo
60+
const expended = unflattenObject(
61+
editorSettingService.editorSetting.settings$.value
62+
);
63+
expect(expended).toMatchObject({
64+
fontFamily: 'Sans',
65+
connector: {
66+
stroke: {
67+
dark: '#000000',
68+
light: '#ffffff',
69+
},
70+
},
71+
});
72+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Entity, LiveData } from '@toeverything/infra';
2+
import { map, type Observable } from 'rxjs';
3+
4+
import type { EditorSettingProvider } from '../provider/editor-setting-provider';
5+
import { EditorSettingSchema } from '../schema';
6+
7+
export class EditorSetting extends Entity {
8+
constructor(public readonly provider: EditorSettingProvider) {
9+
super();
10+
}
11+
12+
settings$ = LiveData.from<EditorSettingSchema>(this.watchAll(), null as any);
13+
14+
set<K extends keyof EditorSettingSchema>(
15+
key: K,
16+
value: EditorSettingSchema[K]
17+
) {
18+
const schema = EditorSettingSchema.shape[key];
19+
20+
this.provider.set(key, JSON.stringify(schema.parse(value)));
21+
}
22+
23+
private watchAll(): Observable<EditorSettingSchema> {
24+
return this.provider.watchAll().pipe(
25+
map(
26+
all =>
27+
Object.fromEntries(
28+
Object.entries(EditorSettingSchema.shape).map(([key, schema]) => {
29+
const value = all[key];
30+
const parsed = schema.safeParse(
31+
value ? JSON.parse(value) : undefined
32+
);
33+
return [
34+
key,
35+
// if parsing fails, return the default value
36+
parsed.success ? parsed.data : schema.parse(undefined),
37+
];
38+
})
39+
) as EditorSettingSchema
40+
)
41+
);
42+
}
43+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { GlobalState } from '@toeverything/infra';
2+
import { Service } from '@toeverything/infra';
3+
import { map, type Observable } from 'rxjs';
4+
5+
import type { EditorSettingProvider } from '../provider/editor-setting-provider';
6+
7+
const storageKey = 'editor-setting';
8+
9+
/**
10+
* just for testing, vary poor performance
11+
*/
12+
export class GlobalStateEditorSettingProvider
13+
extends Service
14+
implements EditorSettingProvider
15+
{
16+
constructor(public readonly globalState: GlobalState) {
17+
super();
18+
}
19+
set(key: string, value: string): void {
20+
const all = this.globalState.get<Record<string, string>>(storageKey) ?? {};
21+
const after = {
22+
...all,
23+
[key]: value,
24+
};
25+
this.globalState.set(storageKey, after);
26+
}
27+
get(key: string): string | undefined {
28+
return this.globalState.get<Record<string, string>>(storageKey)?.[key];
29+
}
30+
watchAll(): Observable<Record<string, string>> {
31+
return this.globalState
32+
.watch<Record<string, string>>(storageKey)
33+
.pipe(map(all => all ?? {}));
34+
}
35+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { type Framework, GlobalState } from '@toeverything/infra';
2+
3+
import { EditorSetting } from './entities/editor-setting';
4+
import { GlobalStateEditorSettingProvider } from './impls/global-state';
5+
import { EditorSettingProvider } from './provider/editor-setting-provider';
6+
import { EditorSettingService } from './services/editor-setting';
7+
8+
export function configureEditorSettingModule(framework: Framework) {
9+
framework
10+
.service(EditorSettingService)
11+
.entity(EditorSetting, [EditorSettingProvider])
12+
.impl(EditorSettingProvider, GlobalStateEditorSettingProvider, [
13+
GlobalState,
14+
]);
15+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createIdentifier } from '@toeverything/infra';
2+
import type { Observable } from 'rxjs';
3+
4+
export interface EditorSettingProvider {
5+
set(key: string, value: string): void;
6+
watchAll(): Observable<Record<string, string>>;
7+
}
8+
9+
export const EditorSettingProvider = createIdentifier<EditorSettingProvider>(
10+
'EditorSettingProvider'
11+
);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { z } from 'zod';
2+
3+
const BSEditorSettingSchema = z.object({
4+
// TODO: import from bs
5+
connector: z.object({
6+
stroke: z
7+
.union([
8+
z.string(),
9+
z.object({
10+
dark: z.string(),
11+
light: z.string(),
12+
}),
13+
])
14+
.default('#000000'),
15+
}),
16+
});
17+
18+
const AffineEditorSettingSchema = z.object({
19+
fontFamily: z.enum(['Sans', 'Serif', 'Mono', 'Custom']).default('Sans'),
20+
});
21+
22+
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (
23+
x: infer I
24+
) => void
25+
? I
26+
: never;
27+
28+
type FlattenZodObject<O, Prefix extends string = ''> =
29+
O extends z.ZodObject<infer T>
30+
? {
31+
[A in keyof T]: T[A] extends z.ZodObject<any>
32+
? A extends string
33+
? FlattenZodObject<T[A], `${Prefix}${A}.`>
34+
: never
35+
: A extends string
36+
? { [key in `${Prefix}${A}`]: T[A] }
37+
: never;
38+
}[keyof T]
39+
: never;
40+
41+
function flattenZodObject<S extends z.ZodObject<any>>(
42+
schema: S,
43+
target: z.ZodObject<any> = z.object({}),
44+
prefix = ''
45+
) {
46+
for (const key in schema.shape) {
47+
const value = schema.shape[key];
48+
if (value instanceof z.ZodObject) {
49+
flattenZodObject(value, target, prefix + key + '.');
50+
} else {
51+
target.shape[prefix + key] = value;
52+
}
53+
}
54+
type Result = UnionToIntersection<FlattenZodObject<S>>;
55+
return target as Result extends z.ZodRawShape ? z.ZodObject<Result> : never;
56+
}
57+
58+
export const EditorSettingSchema = flattenZodObject(
59+
BSEditorSettingSchema.merge(AffineEditorSettingSchema)
60+
);
61+
62+
export type EditorSettingSchema = z.infer<typeof EditorSettingSchema>;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Service } from '@toeverything/infra';
2+
3+
import { EditorSetting } from '../entities/editor-setting';
4+
5+
export class EditorSettingService extends Service {
6+
editorSetting = this.framework.createEntity(EditorSetting);
7+
}

packages/frontend/core/src/modules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { configureCollectionModule } from './collection';
66
import { configureDocLinksModule } from './doc-link';
77
import { configureDocsSearchModule } from './docs-search';
88
import { configureEditorModule } from './editor';
9+
import { configureEditorSettingModule } from './editor-settting';
910
import { configureExplorerModule } from './explorer';
1011
import { configureFavoriteModule } from './favorite';
1112
import { configureFindInPageModule } from './find-in-page';
@@ -43,4 +44,5 @@ export function configureCommonModules(framework: Framework) {
4344
configureThemeEditorModule(framework);
4445
configureEditorModule(framework);
4546
configureSystemFontFamilyModule(framework);
47+
configureEditorSettingModule(framework);
4648
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { expect, test } from 'vitest';
2+
3+
import { unflattenObject } from '../unflatten-object';
4+
5+
test('unflattenObject', () => {
6+
const ob = {
7+
'a.b.c': 1,
8+
d: 2,
9+
};
10+
const result = unflattenObject(ob);
11+
expect(result).toEqual({
12+
a: {
13+
b: {
14+
c: 1,
15+
},
16+
},
17+
d: 2,
18+
});
19+
});

packages/frontend/core/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './fractional-indexing';
55
export * from './popup';
66
export * from './string2color';
77
export * from './toast';
8+
export * from './unflatten-object';

0 commit comments

Comments
 (0)