mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 05:07:06 +08: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 ?? {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
+11
@@ -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'
|
||||||
|
);
|
||||||
@@ -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 { configureDocLinksModule } from './doc-link';
|
||||||
import { configureDocsSearchModule } from './docs-search';
|
import { configureDocsSearchModule } from './docs-search';
|
||||||
import { configureEditorModule } from './editor';
|
import { configureEditorModule } from './editor';
|
||||||
|
import { configureEditorSettingModule } from './editor-settting';
|
||||||
import { configureExplorerModule } from './explorer';
|
import { configureExplorerModule } from './explorer';
|
||||||
import { configureFavoriteModule } from './favorite';
|
import { configureFavoriteModule } from './favorite';
|
||||||
import { configureFindInPageModule } from './find-in-page';
|
import { configureFindInPageModule } from './find-in-page';
|
||||||
@@ -43,4 +44,5 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configureThemeEditorModule(framework);
|
configureThemeEditorModule(framework);
|
||||||
configureEditorModule(framework);
|
configureEditorModule(framework);
|
||||||
configureSystemFontFamilyModule(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 './popup';
|
||||||
export * from './string2color';
|
export * from './string2color';
|
||||||
export * from './toast';
|
export * from './toast';
|
||||||
|
export * from './unflatten-object';
|
||||||
|
|||||||
@@ -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