diff --git a/.dumirc.ts b/.dumirc.ts
index 8c2dc686..2a96296b 100644
--- a/.dumirc.ts
+++ b/.dumirc.ts
@@ -5,11 +5,6 @@ const isProd = process.env.NODE_ENV === 'production';
export default defineConfig({
outputPath: 'docs-dist',
mfsu: false,
- // apiParser: {},
- // resolve: {
- // // 配置入口文件路径,API 解析将从这里开始
- // entryFile: './src/index.ts',
- // },
favicons: ['https://gw.alipayobjects.com/zos/antfincdn/upvrAjAPQX/Logo_Tech%252520UI.svg'],
// @ts-ignore
ssr: false,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5778b53..5144e3a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,38 @@
# Changelog
+## [Version 0.28.0-alpha.1](https://github.com/ant-design/pro-editor/compare/v0.27.0...v0.28.0-alpha.1)
+
+Released on **2023-11-24**
+
+#### ✨ 新特性
+
+- Undo/redo Middleware.
+
+#### 🐛 修复
+
+- Fix compatible with subscribeWithSelector middleware.
+
+
+
+
+Improvements and Fixes
+
+#### What's improved
+
+- Undo/redo Middleware, closes [#74](https://github.com/ant-design/pro-editor/issues/74) ([44551aa](https://github.com/ant-design/pro-editor/commit/44551aa))
+
+#### What's fixed
+
+- Fix compatible with subscribeWithSelector middleware, closes [#89](https://github.com/ant-design/pro-editor/issues/89) ([b11cb36](https://github.com/ant-design/pro-editor/commit/b11cb36))
+
+
+
+
+
+[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
+
+
+
## [Version 0.27.0](https://github.com/ant-design/pro-editor/compare/v0.26.0...v0.27.0)
Released on **2023-11-24**
diff --git a/docs/guide/data-management.md b/docs/guide/data-management.md
index 2e10ece9..15089d1a 100644
--- a/docs/guide/data-management.md
+++ b/docs/guide/data-management.md
@@ -1,5 +1,5 @@
---
-title: 数据流最佳实践
+title: 编辑器数据流最佳实践
group:
title: 状态管理研发
order: 10
@@ -7,7 +7,7 @@ group:
## 数据流最佳实践
-编辑器场景不同于网页,存在大量的富交互能力。如何设计一个易于开发与易于维护的数据流架构非常重要。
+编辑器场景不同于 CRUD 的网页,存在大量的富交互能力,如何设计一个易于开发与易于维护的数据流架构非常重要。
## 概念要素
diff --git a/docs/guide/demos/Redo/App.tsx b/docs/guide/demos/Redo/App.tsx
new file mode 100644
index 00000000..b7b0b91e
--- /dev/null
+++ b/docs/guide/demos/Redo/App.tsx
@@ -0,0 +1,43 @@
+import { Button, Card, Divider, Tabs } from 'antd';
+import { useTheme } from 'antd-style';
+import { Flexbox } from 'react-layout-kit';
+
+import Toolbar from './Toolbar';
+import { useStore } from './store';
+
+const App = () => {
+ const { data, plus, tabs, switchTabs, plusWithoutHistory } = useStore();
+
+ const theme = useTheme();
+
+ return (
+
+
+
+
+
+
+
+ data: {data}
+
+
+
+
+
+
下面的 +2 可使得 在历史记录外添加让 data +2
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/docs/guide/demos/Redo/Toolbar.tsx b/docs/guide/demos/Redo/Toolbar.tsx
new file mode 100644
index 00000000..a491b106
--- /dev/null
+++ b/docs/guide/demos/Redo/Toolbar.tsx
@@ -0,0 +1,45 @@
+import { RedoOutlined, UndoOutlined } from '@ant-design/icons';
+import { useProEditor } from '@ant-design/pro-editor';
+import { Badge, Button } from 'antd';
+import { Flexbox } from 'react-layout-kit';
+
+const Toolbar = () => {
+ const { undo, redo, undoStack, redoStack } = useProEditor();
+
+ const undoStackList = undoStack();
+ const redoStackList = redoStack();
+
+ const lastAction = undoStackList.at(-1);
+
+ return (
+
+
+
+ } onClick={undo} disabled={undoStackList.length === 0}>
+ 撤销
+
+
+
+
+ } onClick={redo} disabled={redoStackList.length === 0}>
+ 重做
+
+
+
+
+ 上次操作时间:
+ {lastAction ? new Date(lastAction.timestamp).toLocaleTimeString() : '-'}
+
+
+ 上次操作名称:
+ {lastAction?.name ?? '-'}
+ {' '}
+
+ 上次操作类型:
+ {lastAction?.type ?? '-'}
+
+
+ );
+};
+
+export default Toolbar;
diff --git a/docs/guide/demos/Redo/index.tsx b/docs/guide/demos/Redo/index.tsx
new file mode 100644
index 00000000..7ff7c566
--- /dev/null
+++ b/docs/guide/demos/Redo/index.tsx
@@ -0,0 +1,15 @@
+/**
+ * compact: true
+ */
+import { ProEditorProvider } from '@ant-design/pro-editor';
+import App from './App';
+
+import { useStore } from './store';
+
+export default () => {
+ return (
+
+
+
+ );
+};
diff --git a/docs/guide/demos/Redo/store.ts b/docs/guide/demos/Redo/store.ts
new file mode 100644
index 00000000..aa0ef1f0
--- /dev/null
+++ b/docs/guide/demos/Redo/store.ts
@@ -0,0 +1,57 @@
+import { proEditorMiddleware, ProEditorOptions } from '@ant-design/pro-editor';
+import { create, StateCreator } from 'zustand';
+import { devtools, subscribeWithSelector } from 'zustand/middleware';
+
+interface Store {
+ tabs: string;
+ plus: () => void;
+ plusWithoutHistory: () => void;
+ data: number;
+ switchTabs: (key: string) => void;
+}
+
+const createStore: StateCreator = (
+ set,
+ get,
+) => ({
+ tabs: '1',
+ switchTabs: (key) => {
+ set({ tabs: key });
+ },
+ plusWithoutHistory: () => {
+ set((s) => ({ ...s, data: s.data + 2 }), false, {
+ type: 'plusWithoutHistory',
+ recordHistory: false,
+ });
+ },
+
+ plus: () => {
+ const nextData = get().data + 1;
+
+ set({ data: nextData }, false, {
+ type: 'plus',
+ payload: nextData,
+ name: '+1',
+ });
+ },
+ data: 3,
+});
+
+interface ProEditorStore {
+ data: number;
+}
+
+const storeName = 'redo-demo-app';
+
+const proEditorOptions: ProEditorOptions = {
+ name: storeName,
+ partialize: (s) => ({ data: s.data }),
+};
+
+export const useStore = create()(
+ devtools(proEditorMiddleware(subscribeWithSelector(createStore), proEditorOptions), {
+ name: storeName,
+ }),
+);
+
+useStore.subscribe((s) => s.data, console.log);
diff --git a/docs/guide/redo-undo.md b/docs/guide/redo-undo.md
new file mode 100644
index 00000000..758eb316
--- /dev/null
+++ b/docs/guide/redo-undo.md
@@ -0,0 +1,113 @@
+---
+title: 撤销重做
+group: 状态管理研发
+order: 2
+---
+
+# 撤销重做
+
+撤销重做是编辑器场景保障用户体验的一个重要特性。ProEditor 作为编辑器框架,为上层的应用编辑器提供了撤销重做的原子化能力。
+
+## 立即上手
+
+
+
+## 使用方式
+
+### 初始化
+
+1. 外层包裹 ProEditorProvider,传入相应的 zustand store
+
+```tsx | pure
+import { ProEditorProvider } from '@ant-design/pro-editor';
+
+import { useStore } from './store';
+
+export default () => {
+ return (
+
+
+
+ );
+};
+```
+
+2. zustand store 包裹 ProEditorMiddleware
+
+```ts
+import { proEditorMiddleware, ProEditorOptions } from '@ant-design/pro-editor';
+
+interface ProEditorStore extends Partial { }
+
+
+const proEditorOptions: ProEditorOptions = {
+ name: 'store-name', // 每个 store 需要有自己的唯一名称
+ partialize: (s) => ({ data: s.data }), // 支持按需接入
+};
+
+export const useStore = create()(
+
+ // createStore 是 StoreCreator 类型的对象
+ proEditorMiddleware(createStore, proEditorOptions)),
+);
+```
+
+多个 Store 使用的方式:
+
+```tsx | pure
+import { ProEditorProvider } from '@ant-design/pro-editor';
+
+import { useAStore } from './storeA';
+import { useBStore } from './storeB';
+
+export default () => {
+ return (
+
+
+
+ );
+};
+```
+
+多 Store 撤销重做互相隔离
+
+```tsx | pure
+
+```
+
+### 设定历史记录
+
+```ts
+const createStore: StateCreator = (
+ set,
+ get,
+) => ({
+ tabs: '1',
+ switchTabs: (key) => {
+ set({ tabs: key });
+ },
+ plusWithoutHistory: () => {
+ set((s) => ({ ...s, data: s.data + 2 }), false, {
+ type: 'plusWithoutHistory',
+ // 不进入历史记录
+ recordHistory: false,
+ });
+ },
+
+ plus: () => {
+ const nextData = get().data + 1;
+
+ // 默认进入历史记录
+ set({ data: nextData }, false, { type: 'plus' });
+ },
+ data: 3,
+});
+```
diff --git a/package.json b/package.json
index 4a1ddfdb..bf92b206 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@ant-design/pro-editor",
- "version": "0.27.0",
+ "version": "0.28.0-alpha.1",
"description": "🌟 Lightweight Editor UI Framework",
"homepage": "https://github.com/ant-design/pro-editor",
"bugs": {
@@ -121,7 +121,7 @@
},
"devDependencies": {
"@emotion/jest": "^11.11.0",
- "@testing-library/jest-dom": "^5.17.0",
+ "@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.1",
"@types/color": "^3.0.4",
@@ -153,7 +153,7 @@
"semantic-release": "^21.1.2",
"semantic-release-config-gitmoji": "^1.5.3",
"stylelint": "^15.10.3",
- "typescript": "~5.1.6",
+ "typescript": "^5.2.2",
"vitest": "latest",
"wait-on": "^6.0.1",
"y-protocols": "^1.0.5",
diff --git a/src/ConfigProvider/index.md b/src/ConfigProvider/index.md
index 091ed36a..eec09058 100644
--- a/src/ConfigProvider/index.md
+++ b/src/ConfigProvider/index.md
@@ -2,8 +2,8 @@
title: ConfigProvider 全局容器
atomId: ConfigProvider
group:
- title: 基础组件
- order: 0
+ title: 其他
+ order: 1000
---
# ConfigProvider 全局容器
diff --git a/src/IconPicker/features/PickerPanel.tsx b/src/IconPicker/features/PickerPanel.tsx
index ce6b191e..54dd3054 100644
--- a/src/IconPicker/features/PickerPanel.tsx
+++ b/src/IconPicker/features/PickerPanel.tsx
@@ -43,7 +43,6 @@ const PickerPanel = () => {
) : undefined}
},
{ label: 'Iconfont', value: 'iconfont' },
diff --git a/src/ProEditor/ProEditorProvider/StoreUpdater.tsx b/src/ProEditor/ProEditorProvider/StoreUpdater.tsx
new file mode 100644
index 00000000..cc76a849
--- /dev/null
+++ b/src/ProEditor/ProEditorProvider/StoreUpdater.tsx
@@ -0,0 +1,99 @@
+import isEqual from 'fast-deep-equal';
+import { produce } from 'immer';
+import { memo, useCallback, useEffect } from 'react';
+import { StoreApi } from 'zustand';
+import { createStoreUpdater, storeApiSetState } from 'zustand-utils';
+import { UseBoundStore } from 'zustand/react';
+import { InjectInternalProEditor } from '../middleware/pro-editor/type';
+import { useStoreApi } from '../store';
+
+interface StoreUpdaterProps {
+ store: UseBoundStore>;
+}
+const StoreUpdater = memo(({ store }) => {
+ const { proEditor } = store.getState();
+
+ // =============== 前置校验 =============== //
+ // 1. 包裹 proEditorMiddleware 2. 包裹 ProEditorProvider
+ if (!proEditor) {
+ throw Error('please wrapper your zustand store with proEditorMiddleware');
+ }
+
+ try {
+ useStoreApi();
+ } catch (e) {
+ throw Error('Please wrap your App with ');
+ }
+
+ const storeApi = useStoreApi();
+ const { yjsDoc, setConfig } = storeApi.getState();
+
+ const configKey = proEditor.options.name;
+
+ const getProEditorConfig = () => {
+ return proEditor.options.partialize(store.getState());
+ };
+
+ const isEqualConfig = () => {
+ const config = getProEditorConfig();
+ return isEqual(config, storeApi.getState().config?.[configKey]);
+ };
+
+ // 将应用层的 store 注入 config
+ const config = getProEditorConfig();
+
+ const useStoreUpdater = createStoreUpdater(storeApi);
+
+ useStoreUpdater('config', { [configKey]: config }, [config], (partialNewState) => {
+ if (isEqualConfig()) return;
+
+ storeApiSetState(storeApi, partialNewState, false, {
+ type: `⤵️ syncData from ${configKey}`,
+ payload: { config, name: configKey },
+ });
+
+ yjsDoc.updateHistoryData(partialNewState);
+ });
+
+ // TODO: 可以看下是否拆成独立的onRedoUndoChange
+ useStoreUpdater(
+ 'onConfigChange',
+ (value) => {
+ const config = value.config[configKey];
+ const prevConfig = getProEditorConfig();
+
+ if (isEqual(prevConfig, config)) return;
+
+ store.setState(
+ config,
+ false,
+ // @ts-ignore
+ { type: 'ProEditor/updateByRedoOrUndo', payload: config },
+ );
+ },
+ [],
+ );
+
+ // =============== 注入与中间件联动的方法
+
+ const updateConfig: typeof setConfig = useCallback((...args) => {
+ if (isEqualConfig()) return;
+
+ setConfig(...args);
+ }, []);
+
+ useEffect(() => {
+ store.setState(
+ produce((draft: InjectInternalProEditor) => {
+ draft.proEditor.__INTERNAL_SET_CONFIG__NOT_USE_IT = updateConfig;
+ }),
+ false,
+ // @ts-ignore
+ 'injectProEditor',
+ );
+ }, []);
+
+ return null;
+});
+
+export default StoreUpdater;
diff --git a/src/ProEditor/ProEditorProvider/index.test.tsx b/src/ProEditor/ProEditorProvider/index.test.tsx
new file mode 100644
index 00000000..c1d40195
--- /dev/null
+++ b/src/ProEditor/ProEditorProvider/index.test.tsx
@@ -0,0 +1,58 @@
+import { ProEditorProvider, proEditorMiddleware } from '@ant-design/pro-editor';
+
+import { render, renderHook, screen } from '@testing-library/react';
+import { create } from 'zustand';
+
+const useAStore = create(
+ proEditorMiddleware(() => ({ a: 'a' }), {
+ name: 'a',
+ }),
+);
+
+const useBStore = create(
+ proEditorMiddleware(() => ({ b: 'b' }), {
+ name: 'b',
+ }),
+);
+
+describe('ProEditorProvider', () => {
+ it('should render children correctly', () => {
+ render(
+
+ Child Component
+ ,
+ );
+
+ expect(screen.getByText('Child Component')).toBeInTheDocument();
+ });
+
+ it('should render Store correctly', () => {
+ const Provider = ({ children }) => (
+ {children}
+ );
+
+ const { result } = renderHook(() => useAStore(), { wrapper: Provider });
+ const { result: bResult } = renderHook(() => useBStore(), { wrapper: Provider });
+
+ // @ts-ignore
+ expect(result.current.proEditor).toBeDefined();
+ // @ts-ignore
+ expect(bResult.current.proEditor).toBeDefined();
+ });
+
+ it('嵌套时使用同一个 Store', () => {
+ const Provider = ({ children }) => (
+ {children}
+ );
+ const AnotherProvider = ({ children }) => (
+
+ {children};
+
+ );
+
+ const { result } = renderHook(() => useAStore(), { wrapper: AnotherProvider });
+
+ // @ts-ignore
+ expect(result.current.proEditor).toBeDefined();
+ });
+});
diff --git a/src/ProEditor/ProEditorProvider/index.tsx b/src/ProEditor/ProEditorProvider/index.tsx
new file mode 100644
index 00000000..66bc1eef
--- /dev/null
+++ b/src/ProEditor/ProEditorProvider/index.tsx
@@ -0,0 +1,42 @@
+import type { FC, ReactNode } from 'react';
+import { DevtoolsOptions } from 'zustand/middleware';
+
+import { StoreApi } from 'zustand/esm';
+import { UseBoundStore } from 'zustand/react';
+import { createStore, Provider, useStoreApi } from '../store';
+import StoreUpdater from './StoreUpdater';
+
+export interface ProEditorProviderProps {
+ children: ReactNode;
+ devtoolOptions?: boolean | DevtoolsOptions;
+ store?: UseBoundStore>[];
+}
+
+export const ProEditorProvider: FC = ({
+ children,
+ devtoolOptions,
+ store,
+}) => {
+ let isWrapped = true;
+
+ const Content = (
+ <>
+ {children}
+ {store?.map((item, index) => (
+
+ ))}
+ >
+ );
+
+ try {
+ useStoreApi();
+ } catch (e) {
+ isWrapped = false;
+ }
+ /* istanbul ignore if */
+ if (isWrapped) {
+ return Content;
+ }
+
+ return createStore(devtoolOptions)}>{Content};
+};
diff --git a/src/ProEditor/hooks/useProEditor.test.ts b/src/ProEditor/hooks/useProEditor.test.ts
new file mode 100644
index 00000000..a1fa6f41
--- /dev/null
+++ b/src/ProEditor/hooks/useProEditor.test.ts
@@ -0,0 +1,48 @@
+import { ProEditorProvider, useProEditor } from '@ant-design/pro-editor';
+import { renderHook } from '@testing-library/react';
+
+describe('useProEditor', () => {
+ it('返回正确的实例类型', () => {
+ const {
+ result: { current: instance },
+ } = renderHook(() => useProEditor<{ name: string }>(), {
+ wrapper: ProEditorProvider,
+ });
+
+ expect(instance).toHaveProperty('getProps');
+ expect(instance).toHaveProperty('getConfig');
+ expect(instance).toHaveProperty('setConfig');
+ expect(instance).toHaveProperty('exportConfig');
+ expect(instance).toHaveProperty('resetConfig');
+
+ expect(instance).toHaveProperty('undo');
+ expect(instance).toHaveProperty('redo');
+ expect(instance).toHaveProperty('undoStack');
+ expect(instance).toHaveProperty('redoStack');
+
+ expect(instance).not.toHaveProperty('props');
+ expect(instance).not.toHaveProperty('config');
+ expect(instance).not.toHaveProperty('onCanvasError');
+ expect(instance).not.toHaveProperty('onEditorAwarenessChange');
+ expect(instance).not.toHaveProperty('onAssetAwarenessChange');
+ expect(instance).not.toHaveProperty('onConfigChange');
+ expect(instance).not.toHaveProperty('onInteractionChange');
+ expect(instance).not.toHaveProperty('internalSetState');
+ expect(instance).not.toHaveProperty('internalUpdateCanvasInteract');
+ expect(instance).not.toHaveProperty('internalUpdatePresenceEditor');
+ expect(instance).not.toHaveProperty('internalUpdatePresenceAsset');
+ expect(instance).not.toHaveProperty('internalUpdateConfig');
+ });
+
+ it('正确获取 config 和 props', () => {
+ const {
+ result: { current: instance },
+ } = renderHook(() => useProEditor<{ name: string; age: number }>(), {
+ wrapper: ProEditorProvider,
+ });
+ const config = { name: 'John' };
+ instance.setConfig(config);
+ expect(instance.getConfig()).toEqual(config);
+ expect(instance.getProps()).toEqual({});
+ });
+});
diff --git a/src/ProEditor/hooks/useProEditor.ts b/src/ProEditor/hooks/useProEditor.ts
new file mode 100644
index 00000000..60b08c33
--- /dev/null
+++ b/src/ProEditor/hooks/useProEditor.ts
@@ -0,0 +1,65 @@
+import { useMemoizedFn } from 'ahooks';
+import { useMemo } from 'react';
+
+import { ConfigPublicAction } from '../store/slices/config';
+import { GeneralPublicAction } from '../store/slices/general';
+
+import { useStoreApi } from '../store';
+
+/**
+ * ProBuilder 实例对象
+ * @template Config - 配置信息的类型
+ * @template Props - 组件属性的类型
+ */
+export interface ProEditorInstance
+ extends ConfigPublicAction,
+ GeneralPublicAction {
+ /**
+ * 获取配置信息
+ * @returns {Config} - 配置信息
+ */
+ getConfig: () => Config | null;
+ /**
+ * 获取组件属性
+ * @returns {Props} - 组件属性
+ */
+ getProps: () => Props;
+}
+
+export const useProEditor = (): ProEditorInstance => {
+ const storeApi = useStoreApi();
+
+ const {
+ undoStack,
+ undoLength,
+ redoLength,
+ redoStack,
+ setConfig,
+ exportConfig,
+ resetConfig,
+ undo,
+ redo,
+ } = storeApi.getState();
+
+ const getConfig = useMemoizedFn(() => storeApi.getState().config);
+ const getProps = useMemoizedFn(() => storeApi.getState().props);
+
+ return useMemo(
+ () => ({
+ getConfig,
+ setConfig,
+ exportConfig,
+ resetConfig,
+
+ undo,
+ redo,
+ undoStack,
+ redoStack,
+ undoLength,
+ redoLength,
+
+ getProps,
+ }),
+ [],
+ );
+};
diff --git a/src/ProEditor/index.ts b/src/ProEditor/index.ts
new file mode 100644
index 00000000..c51b7125
--- /dev/null
+++ b/src/ProEditor/index.ts
@@ -0,0 +1,3 @@
+export * from './ProEditorProvider';
+export * from './hooks/useProEditor';
+export * from './middleware';
diff --git a/src/ProEditor/middleware/index.ts b/src/ProEditor/middleware/index.ts
new file mode 100644
index 00000000..2efcb3d9
--- /dev/null
+++ b/src/ProEditor/middleware/index.ts
@@ -0,0 +1 @@
+export * from './pro-editor';
diff --git a/src/ProEditor/middleware/pro-editor/index.ts b/src/ProEditor/middleware/pro-editor/index.ts
new file mode 100644
index 00000000..ff8dbf35
--- /dev/null
+++ b/src/ProEditor/middleware/pro-editor/index.ts
@@ -0,0 +1,81 @@
+import { StateCreator, StoreMutatorIdentifier } from 'zustand/vanilla';
+import { InjectInternalProEditor, ProEditorImpl, ProEditorSetStateAction } from './type';
+
+/**
+ * 提供给用户的配置项
+ */
+export interface ProEditorOptions {
+ /** Name of the storage (must be unique) */
+ name: string;
+ /**
+ * Filter the persisted value.
+ *
+ * @params state The state's value
+ */
+ partialize?: (state: S) => EditorSaveState;
+}
+
+const middleware: ProEditorImpl = (storeInitializer, options) => (set, get, api) => {
+ const partialize = options.partialize ?? ((s) => s);
+ const configKey = options.name;
+
+ /**
+ * 记录历史
+ * @param action
+ */
+ const updateInProEditor = (action: ProEditorSetStateAction) => {
+ const nextConfig = partialize(get());
+
+ const { proEditor } = get() as InjectInternalProEditor;
+
+ proEditor.__INTERNAL_SET_CONFIG__NOT_USE_IT(
+ { [configKey]: nextConfig },
+ { trigger: 'proEditorMiddleware', ...action },
+ );
+ };
+
+ /**
+ * handle setState function
+ */
+ const savedSetState = api.setState;
+ api.setState = (partial, replace, action) => {
+ savedSetState(partial, replace, action);
+
+ updateInProEditor((action as any) || {});
+ };
+
+ /*
+ * Capture the initial state so that we can initialize the pro editor store to the
+ * same values as the initial values of the Zustand store.
+ */
+ const store = storeInitializer(
+ /*
+ * Create a new set function that defers to the original and then passes
+ * the new state to patchSharedType.
+ */
+ (partial, replace, action) => {
+ set(partial, replace, action);
+ updateInProEditor((action as any) || {});
+ },
+ get,
+ api,
+ );
+
+ // Return the initial state to create or the next middleware.
+ return {
+ ...store,
+ proEditor: { options: { ...options, partialize } },
+ };
+};
+
+export type ProEditorMiddleware = <
+ T,
+ Mps extends [StoreMutatorIdentifier, unknown][] = [],
+ Mcs extends [StoreMutatorIdentifier, unknown][] = [],
+ U = T,
+>(
+ initializer: StateCreator,
+ options: ProEditorOptions,
+) => StateCreator;
+
+export const proEditorMiddleware = middleware as unknown as ProEditorMiddleware;
diff --git a/src/ProEditor/middleware/pro-editor/type.ts b/src/ProEditor/middleware/pro-editor/type.ts
new file mode 100644
index 00000000..f93a9e94
--- /dev/null
+++ b/src/ProEditor/middleware/pro-editor/type.ts
@@ -0,0 +1,71 @@
+import { StateCreator } from 'zustand/vanilla';
+import { ConfigPublicAction } from '../../store/slices/config';
+import { TakeTwo, Write } from '../types/utils';
+
+export interface ProEditorSetStateAction {
+ type: unknown;
+
+ /**
+ * 如果记录,那么历史记录的名字是什么
+ */
+ name?: string;
+ /**
+ * 是否将操作记录到历史记录中
+ */
+ recordHistory?: boolean;
+}
+
+/**
+ * 用于给注入方法添加额外的 proEditor 实例对象
+ */
+export interface ProEditorMiddlewareInjectMethod {
+ setState(...a: [...a: TakeTwo, action?: A1]): Sr;
+
+ proEditor: {
+ undo?: () => void;
+ redo?: () => void;
+ // clearStorage: () => void;
+ getOptions: () => Partial>;
+ };
+}
+/**
+ * 提供给用户的配置项
+ */
+export interface ProEditorOptions {
+ /** Name of the storage (must be unique) */
+ name: string;
+ /**
+ * Filter the persisted value.
+ *
+ * @params state The state's value
+ */
+ partialize?: (state: S) => EditorSaveState;
+}
+
+// 为 mutator 注入 'pro-editor' 类型,以支持第三个配置参数
+
+type WithProEditor = S extends {
+ getState: () => infer T;
+ setState: (...a: infer Sa) => infer Sr;
+}
+ ? Write>
+ : never;
+
+declare module 'zustand/vanilla' {
+ interface StoreMutators {
+ ['pro-editor']: WithProEditor;
+ }
+}
+
+// 内部方法,用于保证中间件内部的类型定义安全
+
+export type ProEditorImpl = (
+ storeInitializer: StateCreator,
+ options: ProEditorOptions,
+) => StateCreator;
+
+export interface InjectInternalProEditor {
+ proEditor: {
+ __INTERNAL_SET_CONFIG__NOT_USE_IT: ConfigPublicAction['setConfig'];
+ };
+}
diff --git a/src/ProEditor/middleware/types/utils.ts b/src/ProEditor/middleware/types/utils.ts
new file mode 100644
index 00000000..b5f4fa1d
--- /dev/null
+++ b/src/ProEditor/middleware/types/utils.ts
@@ -0,0 +1,25 @@
+export type Cast = T extends U ? T : U;
+
+export type TakeTwo = T extends { length: 0 }
+ ? [undefined, undefined]
+ : T extends { length: 1 }
+ ? [...a0: Cast, a1: undefined]
+ : T extends { length: 0 | 1 }
+ ? [...a0: Cast, a1: undefined]
+ : T extends { length: 2 }
+ ? T
+ : T extends { length: 1 | 2 }
+ ? T
+ : T extends { length: 0 | 1 | 2 }
+ ? T
+ : T extends [infer A0, infer A1, ...unknown[]]
+ ? [A0, A1]
+ : T extends [infer A0, (infer A1)?, ...unknown[]]
+ ? [A0, A1?]
+ : T extends [(infer A0)?, (infer A1)?, ...unknown[]]
+ ? [A0?, A1?]
+ : never;
+
+// 为 mutator 注入 'pro-editor' 类型,以支持第三个配置参数
+
+export type Write = Omit & U;
diff --git a/src/ProEditor/store/__snapshots__/createStore.test.ts.snap b/src/ProEditor/store/__snapshots__/createStore.test.ts.snap
new file mode 100644
index 00000000..ed251c42
--- /dev/null
+++ b/src/ProEditor/store/__snapshots__/createStore.test.ts.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`proEditorStore > 方法 > 修改配置 > 受控模式,有 componentAssets > 内部 config 更新时,内部 props 更新,同时外部值可接受到 props、config 值变化 1`] = `{}`;
+
+exports[`proEditorStore > 方法 > 修改配置 > 受控模式,有 componentAssets > 内部 config 更新时,内部 props 更新,同时外部值可接受到 props、config 值变化 2`] = `undefined`;
+
+exports[`proEditorStore > 方法 > 修改配置 > 受控模式,有 componentAssets > 外部设置 config 时,内部 props 会跟随 config 更新,不触发 onChange 1`] = `{}`;
diff --git a/src/ProEditor/store/__test__/config.test.ts b/src/ProEditor/store/__test__/config.test.ts
new file mode 100644
index 00000000..75f30e6d
--- /dev/null
+++ b/src/ProEditor/store/__test__/config.test.ts
@@ -0,0 +1,88 @@
+import { act, renderHook } from '@testing-library/react';
+
+import { beforeEach, vi } from 'vitest';
+import { createStore } from '../createStore';
+
+vi.mock('zustand');
+
+let useStore = createStore();
+
+beforeEach(() => {
+ useStore = createStore();
+});
+
+describe('configSlice', () => {
+ it('should initialize state correctly', () => {
+ const { result } = renderHook(() => useStore());
+
+ expect(result.current.yjsDoc).toBeDefined();
+ expect(result.current.config).toBeNull();
+ expect(result.current.onConfigChange).toBeNull();
+ expect(result.current.props).toEqual({});
+ });
+
+ it('should reset config and props to initial state', () => {
+ const { result } = renderHook(() => useStore());
+
+ act(() => {
+ useStore.setState({ props: { 123: 1 }, config: { abc: 'abc' } });
+ });
+
+ expect(result.current.props).toEqual({ 123: 1 });
+ expect(result.current.config).toEqual({ abc: 'abc' });
+
+ act(() => {
+ result.current.resetConfig();
+ });
+
+ expect(result.current.props).toEqual({});
+ expect(result.current.config).toEqual(null);
+ });
+
+ it('should update config and trigger onConfigChange callback', () => {
+ const { result } = renderHook(() => useStore());
+
+ const onConfigChange = vi.fn();
+
+ act(() => {
+ useStore.setState({ onConfigChange });
+
+ result.current.internalUpdateConfig({ foo: 'bar' });
+ });
+
+ expect(onConfigChange).toHaveBeenCalledWith({
+ config: { foo: 'bar' },
+ });
+ });
+
+ it('should update config and record history data', () => {
+ const { result } = renderHook(() => useStore());
+ const internalUpdateConfig = vi.fn();
+
+ act(() => {
+ useStore.setState({ internalUpdateConfig });
+
+ result.current.setConfig({ foo: 'bar' }, { recordHistory: true });
+ });
+
+ expect(result.current.yjsDoc.undoManager.undoStack).toHaveLength(1);
+ });
+
+ it('should update config without recording history data', () => {
+ const { result } = renderHook(() => useStore());
+
+ act(() => {
+ result.current.setConfig({ foo: 'bar' }, { recordHistory: false });
+ });
+
+ expect(result.current.config).toEqual({ foo: 'bar' });
+
+ expect(result.current.yjsDoc.undoManager.undoStack).toHaveLength(0);
+
+ act(() => {
+ result.current.setConfig({ foo: 'abc' });
+ });
+
+ expect(result.current.yjsDoc.undoManager.undoStack).toHaveLength(1);
+ });
+});
diff --git a/src/ProEditor/store/createStore.test.ts b/src/ProEditor/store/createStore.test.ts
new file mode 100644
index 00000000..12789b10
--- /dev/null
+++ b/src/ProEditor/store/createStore.test.ts
@@ -0,0 +1,154 @@
+import { act, renderHook } from '@testing-library/react';
+import { useEffect, useState } from 'react';
+
+import { createStore } from './createStore';
+
+vi.mock('zustand/traditional');
+
+const useStore = createStore();
+
+interface AssetConfig {
+ data: any;
+ columns: any;
+}
+
+describe('proEditorStore', () => {
+ describe('方法', () => {
+ describe('修改配置', () => {
+ it('默认', () => {
+ const { result } = renderHook(() => useStore());
+
+ expect(result.current.config).toEqual(null);
+
+ act(() => {
+ result.current.internalUpdateConfig({ hello: 'world' });
+ });
+
+ expect(result.current.config).toEqual({ hello: 'world' });
+ });
+ it('受控模式:没有 componentAssets 则不生成 props', () => {
+ type Config = { text: string };
+ const useTextStore = createStore();
+
+ const { result } = renderHook(() => {
+ const [value, onChange] = useState({ text: '' });
+ const [p, onPropsChange] = useState();
+ const store = useTextStore();
+
+ useEffect(() => {
+ useTextStore.setState({
+ onConfigChange: ({ config, props }) => {
+ onChange(config);
+ onPropsChange(props);
+ },
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!value) return;
+ useTextStore.setState({ config: value });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value]);
+
+ return { store, value, props: p };
+ });
+
+ expect(result.current.store.config).toEqual({ text: '' });
+ expect(result.current.props).toBeUndefined();
+
+ act(() => {
+ result.current.store.internalUpdateConfig({ text: '2' });
+ });
+
+ expect(result.current.store.config).toEqual({ text: '2' });
+ expect(result.current.value).toEqual({ text: '2' });
+ expect(result.current.props).toBeUndefined();
+ });
+ describe('受控模式,有 componentAssets', () => {
+ const useTestStore = createStore();
+
+ const useTestHook = () => {
+ const [value, onChange] = useState();
+ const [p, onPropsChange] = useState();
+ const store = useTestStore();
+
+ useEffect(() => {
+ useTestStore.setState({
+ onConfigChange: ({ config, props }) => {
+ onChange(config as any);
+ onPropsChange(props);
+ },
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!value) return;
+ useTestStore.setState({ config: value });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value]);
+
+ return {
+ store,
+ setConfig: onChange,
+ value,
+ props: p,
+ };
+ };
+
+ it('内部 config 更新时,内部 props 更新,同时外部值可接受到 props、config 值变化', () => {
+ const { result } = renderHook(useTestHook);
+
+ // 内部值
+ expect(result.current.store.config).toBeNull();
+ expect(result.current.store.props).toEqual({});
+
+ // 外部值
+ expect(result.current.props).toBeUndefined();
+
+ const config = {
+ data: { dataType: 'oneApi' },
+ columns: [{ dataIndex: 'a', title: 'hello' }],
+ };
+
+ act(() => {
+ result.current.store.internalUpdateConfig(config);
+ });
+
+ // 内部值
+ expect(result.current.store.config).toEqual(config);
+ expect(result.current.store.props).toMatchSnapshot();
+
+ // 外部值
+ expect(result.current.value).toEqual(config);
+ expect(result.current.props).toMatchSnapshot();
+ });
+
+ it('外部设置 config 时,内部 props 会跟随 config 更新,不触发 onChange', () => {
+ const { result } = renderHook(useTestHook);
+
+ expect(result.current.store.config).toBeNull();
+ expect(result.current.store.props).toEqual({});
+
+ const config = {
+ data: { dataType: 'oneApi' },
+ columns: [{ dataIndex: 'a', title: 'hello' }],
+ };
+
+ act(() => {
+ result.current.setConfig(config);
+ });
+
+ // 内部值
+ expect(result.current.store.config).toEqual(config);
+ expect(result.current.store.props).toMatchSnapshot();
+
+ // 外部值
+ expect(result.current.value).toEqual(config);
+ expect(result.current.props).toBeUndefined();
+ });
+ });
+ });
+ });
+});
diff --git a/src/ProEditor/store/createStore.ts b/src/ProEditor/store/createStore.ts
new file mode 100644
index 00000000..cb64418a
--- /dev/null
+++ b/src/ProEditor/store/createStore.ts
@@ -0,0 +1,35 @@
+import isEqual from 'fast-deep-equal';
+import type { StateCreator } from 'zustand';
+import { optionalDevtools } from 'zustand-utils';
+import { DevtoolsOptions } from 'zustand/middleware';
+import { createWithEqualityFn } from 'zustand/traditional';
+
+import { ConfigPublicState, ConfigSlice, configSlice } from './slices/config';
+import { GeneralSlice, generalSlice } from './slices/general';
+
+/**
+ * ProEditorState 接口描述编辑器状态
+ * @template Config - 编辑器配置属性类型
+ */
+export type ProEditorState = ConfigPublicState;
+
+export type InternalProEditorStore = ProEditorState & ConfigSlice & GeneralSlice;
+
+const vanillaStore: StateCreator = (
+ ...params
+) => ({
+ ...generalSlice(...params),
+ ...configSlice(...params),
+});
+
+export const createStore = (options: boolean | DevtoolsOptions = false) => {
+ const devtools = optionalDevtools(options !== false);
+
+ const devtoolOptions =
+ options === false ? undefined : options === true ? { name: 'ProEditorStore' } : options;
+
+ return createWithEqualityFn()(
+ devtools(vanillaStore, devtoolOptions),
+ isEqual,
+ );
+};
diff --git a/src/ProEditor/store/index.ts b/src/ProEditor/store/index.ts
new file mode 100644
index 00000000..470f9c7c
--- /dev/null
+++ b/src/ProEditor/store/index.ts
@@ -0,0 +1,11 @@
+import { StoreApi } from 'zustand';
+import { createContext } from 'zustand-utils';
+import { InternalProEditorStore, createStore } from './createStore';
+
+const { Provider, useStore, useStoreApi } = createContext>();
+
+// ======== 导出 ======== //
+
+export { Provider, createStore, useStore, useStoreApi };
+
+export type { InternalProEditorStore, ProEditorState } from './createStore';
diff --git a/src/ProEditor/store/slices/config.ts b/src/ProEditor/store/slices/config.ts
new file mode 100644
index 00000000..c63af67f
--- /dev/null
+++ b/src/ProEditor/store/slices/config.ts
@@ -0,0 +1,146 @@
+import isEqual from 'fast-deep-equal';
+import merge from 'lodash.merge';
+import { StateCreator } from 'zustand';
+
+import { DocWithHistoryManager } from '../../utils/yjs';
+import { InternalProEditorStore } from '../createStore';
+
+// ======== state ======== //
+
+interface EditorOnChangePayload {
+ config: Config;
+ props: any;
+}
+
+export type OnConfigChange = (payload: EditorOnChangePayload) => void;
+
+export interface ConfigPublicState {
+ /** 编辑器的配置属性 */
+ config?: Config;
+ configToProps?: (config: Config) => any;
+ /**
+ * 编辑器的配置属性变化时的回调函数
+ * @param config - 编辑器的配置属性
+ */
+ onConfigChange?: OnConfigChange;
+}
+
+export interface ConfigSliceState extends ConfigPublicState {
+ /** 组件的 props */
+ props?: any;
+ yjsDoc: DocWithHistoryManager<{ config: any }>;
+}
+
+// ======== action ======== //
+
+export interface ActionPayload {
+ type: string;
+ payload: any;
+}
+
+export interface ActionOptions {
+ recordHistory?: boolean;
+ replace?: boolean;
+ trigger?: string;
+ type?: unknown;
+ name?: string;
+}
+
+/**
+ * 公共配置操作接口
+ */
+export interface ConfigPublicAction {
+ /**
+ * 导出配置
+ */
+ exportConfig: () => void;
+ /**
+ * 重置配置
+ */
+ resetConfig: () => void;
+ /**
+ * 更新配置
+ * @template T - 配置对象类型
+ * @param {Partial} config - 需要更新的配置对象
+ * @param {ActionOptions} [options] - 配置项
+ */
+ setConfig: (config: Partial, options?: ActionOptions) => void;
+}
+
+export interface ConfigSlice extends ConfigPublicAction, ConfigSliceState {
+ /**
+ * 内部更新配置
+ **/
+ internalUpdateConfig: (config: Partial, payload?: ActionPayload, replace?: boolean) => void;
+}
+
+export const configSlice: StateCreator<
+ InternalProEditorStore,
+ [['zustand/devtools', never]],
+ [],
+ ConfigSlice
+> = (set, get) => {
+ const initialConfigState: ConfigSliceState = {
+ // 文件配置属性
+ config: null,
+ onConfigChange: null,
+ props: {},
+ yjsDoc: new DocWithHistoryManager<{ config: any }>(),
+ };
+
+ return {
+ ...initialConfigState,
+
+ resetConfig: () => {
+ set({ config: initialConfigState.config, props: initialConfigState.props });
+ },
+ /**
+ * 内部修改 config 方法
+ * 传给 ProTableStore 进行 config 同步
+ */
+ internalUpdateConfig: (config, payload, replace) => {
+ const { onConfigChange, configToProps } = get();
+
+ const nextConfig = replace ? config : { ...get().config, ...config };
+
+ set({ config: nextConfig }, false, payload);
+
+ onConfigChange?.({
+ config: nextConfig,
+ props: configToProps?.(nextConfig),
+ });
+ },
+
+ exportConfig: () => {
+ const eleLink = document.createElement('a');
+ eleLink.download = 'pro-edior-config.json';
+ eleLink.style.display = 'none';
+ const blob = new Blob([JSON.stringify(get().config)]);
+ eleLink.href = URL.createObjectURL(blob);
+ document.body.appendChild(eleLink);
+ eleLink.click();
+ document.body.removeChild(eleLink);
+ },
+
+ setConfig: (config, options = {}) => {
+ if (isEqual(config, get().config)) return;
+
+ const { replace, recordHistory, name, type, trigger } = options;
+
+ get().internalUpdateConfig(
+ config,
+ {
+ type: `setConfig/${trigger || 'unknown'}`,
+ payload: { config, options },
+ },
+ replace,
+ );
+
+ const useAction = merge({}, { recordHistory: true }, { recordHistory, name, type });
+
+ if (useAction.recordHistory) {
+ get().yjsDoc.recordHistoryData({ config }, { ...useAction, timestamp: Date.now() });
+ }
+ },
+ };
+};
diff --git a/src/ProEditor/store/slices/general.ts b/src/ProEditor/store/slices/general.ts
new file mode 100644
index 00000000..549118b7
--- /dev/null
+++ b/src/ProEditor/store/slices/general.ts
@@ -0,0 +1,74 @@
+import { StackItem as YJSStackItem } from 'yjs/dist/src/utils/UndoManager';
+import { StateCreator } from 'zustand';
+
+import { InternalProEditorStore } from '../createStore';
+
+export interface StackItem {
+ timestamp: number;
+ name?: string;
+ type?: string;
+}
+/**
+ * 通用公共动作
+ */
+export interface GeneralPublicAction {
+ /**
+ * 撤销操作
+ */
+ undo: () => void;
+ /**
+ * 重做操作
+ */
+ redo: () => void;
+ /**
+ * 撤销栈
+ */
+ undoStack: () => StackItem[];
+ /**
+ * 重做栈
+ */
+ redoStack: () => StackItem[];
+
+ undoLength: () => number;
+ redoLength: () => number;
+}
+
+export type GeneralSlice = GeneralPublicAction;
+
+const mapUndoManagerStackToUserStack = (stack: YJSStackItem[]) =>
+ stack.map((i) => ({
+ name: i.meta.get('name'),
+ timestamp: i.meta.get('timestamp'),
+ type: i.meta.get('type'),
+ }));
+
+export const generalSlice: StateCreator<
+ InternalProEditorStore,
+ [['zustand/devtools', never]],
+ [],
+ GeneralSlice
+> = (set, get) => ({
+ undoStack: () => mapUndoManagerStackToUserStack(get().yjsDoc.undoManager.undoStack),
+ redoStack: () => mapUndoManagerStackToUserStack(get().yjsDoc.undoManager.redoStack),
+
+ undoLength: () => get().yjsDoc.undoManager.undoStack.length,
+ redoLength: () => get().yjsDoc.undoManager.redoStack.length,
+
+ undo: () => {
+ const { yjsDoc, internalUpdateConfig } = get();
+ const stack = yjsDoc.undo();
+
+ const { config } = yjsDoc.getHistoryJSON();
+
+ internalUpdateConfig(config, { type: 'history/undo', payload: stack }, true);
+ },
+ redo: () => {
+ const { yjsDoc, internalUpdateConfig } = get();
+
+ const stack = yjsDoc.redo();
+
+ const { config } = yjsDoc.getHistoryJSON();
+
+ internalUpdateConfig(config, { type: 'history/redo', payload: stack }, true);
+ },
+});
diff --git a/src/ProEditor/utils/yjs.ts b/src/ProEditor/utils/yjs.ts
new file mode 100644
index 00000000..f046c58b
--- /dev/null
+++ b/src/ProEditor/utils/yjs.ts
@@ -0,0 +1,82 @@
+import { Doc, UndoManager } from 'yjs';
+import { AbstractType } from 'yjs/dist/src/types/AbstractType';
+import { DocOpts } from 'yjs/dist/src/utils/Doc';
+import { Transaction } from 'yjs/dist/src/utils/Transaction';
+import { StackItem } from 'yjs/dist/src/utils/UndoManager';
+import { YEvent } from 'yjs/dist/src/utils/YEvent';
+
+export interface UserActionParams {
+ type: string;
+ name: string;
+ timestamp: number;
+}
+
+class UserAction {
+ type;
+ name;
+ timestamp;
+ constructor(params: UserActionParams) {
+ this.type = params.type;
+ this.name = params.name;
+ this.timestamp = params.timestamp;
+ }
+}
+
+interface UndoEvent extends Transaction {
+ type: 'undo' | 'redo';
+ origin: UserAction;
+ stackItem: StackItem;
+ changedParentTypes: Map>, Array>>;
+}
+
+export class DocWithHistoryManager extends Doc {
+ private _internalHistoryKey = '__INTERNAL_HISTORY_MAP__';
+
+ constructor(params?: DocOpts) {
+ super(params);
+
+ this.undoManager = new UndoManager(this.getHistoryMap(), {
+ trackedOrigins: new Set([UserAction]),
+ });
+
+ this.undoManager.on('stack-item-added', (e: UndoEvent) => {
+ e.stackItem.meta.set('timestamp', e.origin.timestamp);
+ e.stackItem.meta.set('type', e.origin.type);
+ e.stackItem.meta.set('name', e.origin.name);
+ });
+ }
+
+ public undoManager: UndoManager;
+
+ updateHistoryData = (value: Partial) => {
+ const map = this.getMap(this._internalHistoryKey);
+
+ Object.entries(value).forEach(([key, value]) => {
+ map.set(key, value);
+ });
+ };
+
+ recordHistoryData = (value: Partial, userAction: UserActionParams) => {
+ this.transact(() => {
+ this.updateHistoryData(value);
+ }, new UserAction(userAction));
+ };
+
+ getHistoryMap = () => {
+ return this.getMap(this._internalHistoryKey);
+ };
+
+ getHistoryJSON = () => {
+ const map = this.getMap(this._internalHistoryKey);
+
+ return map.toJSON() as T;
+ };
+
+ redo = () => {
+ return this.undoManager.redo();
+ };
+
+ undo = () => {
+ return this.undoManager.undo();
+ };
+}
diff --git a/src/index.ts b/src/index.ts
index 91f56c73..fa4e5df2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -29,6 +29,7 @@ export type { LevaPanelProps } from './LevaPanel';
export { default as Markdown, type MarkdownProps } from './Markdown';
export * from './ProBuilder';
export * from './Snippet';
+export * from './ProEditor';
export * from './SortableList';
export * from './SortableTree';
export { default as TipGuide } from './TipGuide';
diff --git a/tests/test-setup.ts b/tests/test-setup.ts
index e73dbd9c..12ba2d4c 100644
--- a/tests/test-setup.ts
+++ b/tests/test-setup.ts
@@ -1,3 +1,4 @@
+import '@testing-library/jest-dom/vitest';
import { theme } from 'antd';
// Not use dynamic hashed for test env since version will change hash dynamically.
diff --git a/tsconfig-check.json b/tsconfig-check.json
index 690fe433..e0d10a32 100644
--- a/tsconfig-check.json
+++ b/tsconfig-check.json
@@ -3,5 +3,5 @@
"compilerOptions": {
"noEmit": true
},
- "include": ["src"]
+ "include": ["src", "tests"]
}