From 44551aa162016922b933d356ea294211832e8ac0 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Tue, 5 Sep 2023 11:48:42 +0800 Subject: [PATCH 1/3] :sparkles: feat: undo/redo Middleware (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :construction: wip: undo ing * :sparkles: feat: 初步实现 undo redo 方法 * :sparkles: feat: 支持多个 Store 共存的能力 * :sparkles: feat: 支持历史记录的操作明细 * :recycle: refactor: refactor the ProEditorProvider * :white_check_mark: test: fix test * :memo: docs: 更新 redo undo 文档 * :label: feat: 兼容 internal type * :white_check_mark: test: update test * :camera_flash: test: update snapshot * :white_check_mark: test: 补充测试 * :memo: docs: 补充使用文档 * :rotating_light: chore: fix lint * :memo: docs: 更新文档 * :rewind: chore: revert ProBuilder 的变更 --- .dumirc.ts | 5 - docs/guide/data-management.md | 4 +- docs/guide/demos/Redo/App.tsx | 43 +++++ docs/guide/demos/Redo/Toolbar.tsx | 45 +++++ docs/guide/demos/Redo/index.tsx | 15 ++ docs/guide/demos/Redo/store.ts | 53 ++++++ docs/guide/redo-undo.md | 115 +++++++++++++ package.json | 4 +- src/ConfigProvider/index.md | 4 +- src/IconPicker/features/PickerPanel.tsx | 1 - .../ProEditorProvider/StoreUpdater.tsx | 99 +++++++++++ .../ProEditorProvider/index.test.tsx | 58 +++++++ src/ProEditor/ProEditorProvider/index.tsx | 42 +++++ src/ProEditor/hooks/useProEditor.test.ts | 48 ++++++ src/ProEditor/hooks/useProEditor.ts | 65 ++++++++ src/ProEditor/index.ts | 3 + src/ProEditor/middleware/index.ts | 1 + src/ProEditor/middleware/pro-editor/index.ts | 78 +++++++++ src/ProEditor/middleware/pro-editor/type.ts | 71 ++++++++ src/ProEditor/middleware/types/utils.ts | 25 +++ .../__snapshots__/createStore.test.ts.snap | 7 + src/ProEditor/store/__test__/config.test.ts | 88 ++++++++++ src/ProEditor/store/createStore.test.ts | 154 ++++++++++++++++++ src/ProEditor/store/createStore.ts | 35 ++++ src/ProEditor/store/index.ts | 11 ++ src/ProEditor/store/slices/config.ts | 146 +++++++++++++++++ src/ProEditor/store/slices/general.ts | 74 +++++++++ src/ProEditor/utils/yjs.ts | 82 ++++++++++ src/index.ts | 1 + tests/test-setup.ts | 1 + tsconfig-check.json | 2 +- 31 files changed, 1367 insertions(+), 13 deletions(-) create mode 100644 docs/guide/demos/Redo/App.tsx create mode 100644 docs/guide/demos/Redo/Toolbar.tsx create mode 100644 docs/guide/demos/Redo/index.tsx create mode 100644 docs/guide/demos/Redo/store.ts create mode 100644 docs/guide/redo-undo.md create mode 100644 src/ProEditor/ProEditorProvider/StoreUpdater.tsx create mode 100644 src/ProEditor/ProEditorProvider/index.test.tsx create mode 100644 src/ProEditor/ProEditorProvider/index.tsx create mode 100644 src/ProEditor/hooks/useProEditor.test.ts create mode 100644 src/ProEditor/hooks/useProEditor.ts create mode 100644 src/ProEditor/index.ts create mode 100644 src/ProEditor/middleware/index.ts create mode 100644 src/ProEditor/middleware/pro-editor/index.ts create mode 100644 src/ProEditor/middleware/pro-editor/type.ts create mode 100644 src/ProEditor/middleware/types/utils.ts create mode 100644 src/ProEditor/store/__snapshots__/createStore.test.ts.snap create mode 100644 src/ProEditor/store/__test__/config.test.ts create mode 100644 src/ProEditor/store/createStore.test.ts create mode 100644 src/ProEditor/store/createStore.ts create mode 100644 src/ProEditor/store/index.ts create mode 100644 src/ProEditor/store/slices/config.ts create mode 100644 src/ProEditor/store/slices/general.ts create mode 100644 src/ProEditor/utils/yjs.ts 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/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 ( + + + + + + + + + + + + 上次操作时间: + {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..febae34a --- /dev/null +++ b/docs/guide/demos/Redo/store.ts @@ -0,0 +1,53 @@ +import { proEditorMiddleware, ProEditorOptions } from '@ant-design/pro-editor'; +import { create, StateCreator } from 'zustand'; +import { devtools } 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(createStore, proEditorOptions), { name: storeName }), +); diff --git a/docs/guide/redo-undo.md b/docs/guide/redo-undo.md new file mode 100644 index 00000000..0d7adb69 --- /dev/null +++ b/docs/guide/redo-undo.md @@ -0,0 +1,115 @@ +--- +title: 撤销重做 +group: 状态管理研发 +order: 2 +--- + +# 撤销重做 + +撤销重做是编辑器场景保障用户体验的一个重要特性。ProEditor 作为编辑器框架,为上层的应用编辑器提供了撤销重做的原子化能力。 + +## 立即上手 + + + +## 使用方式 + +### 初始化 + +1. 外层包裹 ProEditorProvider,传入相应的 zustand store + +```tsx | pure +import { ProEditorProvider, ProEditorStoreUpdater } 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, ProEditorStoreUpdater } 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..33b2b1be 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ }, "devDependencies": { "@emotion/jest": "^11.11.0", - "@testing-library/jest-dom": "^5.17.0", + "@testing-library/jest-dom": "^6.1.2", "@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..2ca0a3f6 --- /dev/null +++ b/src/ProEditor/middleware/pro-editor/index.ts @@ -0,0 +1,78 @@ +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 }, + ); + }; + /* + * 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, + // Create a new setState function as we did with set. + setState: (partial, replace, action) => { + api.setState(partial, replace, action); + + updateInProEditor((action as any) || {}); + }, + }, + ); + + // 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"] } From b11cb36350e3a664cd51b3c95915c93d453a20ed Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Thu, 14 Sep 2023 14:47:08 +0800 Subject: [PATCH 2/3] :bug: fix: fix compatible with subscribeWithSelector middleware (#89) * :bug: fix: fix compatible with subscribe api * :white_check_mark: test: upgrade @testing-library/jest-dom --- docs/guide/demos/Redo/store.ts | 8 ++++++-- docs/guide/redo-undo.md | 14 ++++++------- package.json | 2 +- src/ProEditor/middleware/pro-editor/index.ts | 21 +++++++++++--------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/docs/guide/demos/Redo/store.ts b/docs/guide/demos/Redo/store.ts index febae34a..aa0ef1f0 100644 --- a/docs/guide/demos/Redo/store.ts +++ b/docs/guide/demos/Redo/store.ts @@ -1,6 +1,6 @@ import { proEditorMiddleware, ProEditorOptions } from '@ant-design/pro-editor'; import { create, StateCreator } from 'zustand'; -import { devtools } from 'zustand/middleware'; +import { devtools, subscribeWithSelector } from 'zustand/middleware'; interface Store { tabs: string; @@ -49,5 +49,9 @@ const proEditorOptions: ProEditorOptions = { }; export const useStore = create()( - devtools(proEditorMiddleware(createStore, proEditorOptions), { name: storeName }), + 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 index 0d7adb69..758eb316 100644 --- a/docs/guide/redo-undo.md +++ b/docs/guide/redo-undo.md @@ -19,14 +19,13 @@ order: 2 1. 外层包裹 ProEditorProvider,传入相应的 zustand store ```tsx | pure -import { ProEditorProvider, ProEditorStoreUpdater } from '@ant-design/pro-editor'; +import { ProEditorProvider } from '@ant-design/pro-editor'; import { useStore } from './store'; - export default () => { return ( - + ); @@ -56,15 +55,14 @@ export const useStore = create()( 多个 Store 使用的方式: ```tsx | pure -import { ProEditorProvider, ProEditorStoreUpdater } from '@ant-design/pro-editor'; +import { ProEditorProvider } from '@ant-design/pro-editor'; import { useAStore } from './storeA'; import { useBStore } from './storeB'; - export default () => { return ( - + ); @@ -75,11 +73,11 @@ export default () => { ```tsx | pure
diff --git a/package.json b/package.json index 33b2b1be..fa68c600 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ }, "devDependencies": { "@emotion/jest": "^11.11.0", - "@testing-library/jest-dom": "^6.1.2", + "@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", diff --git a/src/ProEditor/middleware/pro-editor/index.ts b/src/ProEditor/middleware/pro-editor/index.ts index 2ca0a3f6..ff8dbf35 100644 --- a/src/ProEditor/middleware/pro-editor/index.ts +++ b/src/ProEditor/middleware/pro-editor/index.ts @@ -33,6 +33,17 @@ const middleware: ProEditorImpl = (storeInitializer, options) => (set, get, api) { 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. @@ -47,15 +58,7 @@ const middleware: ProEditorImpl = (storeInitializer, options) => (set, get, api) updateInProEditor((action as any) || {}); }, get, - { - ...api, - // Create a new setState function as we did with set. - setState: (partial, replace, action) => { - api.setState(partial, replace, action); - - updateInProEditor((action as any) || {}); - }, - }, + api, ); // Return the initial state to create or the next middleware. From 4636b92bec8dd9f027b58d483234461107a5d748 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 24 Nov 2023 09:15:39 +0000 Subject: [PATCH 3/3] :bookmark: chore(release): v0.28.0-alpha.1 [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [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)
--- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) 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/package.json b/package.json index fa68c600..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": {