From c88698ab1b80eae0c31f76603e5db3830b551387 Mon Sep 17 00:00:00 2001 From: arvinxx Date: Tue, 13 Jun 2023 16:10:29 +0800 Subject: [PATCH] :sparkles: feat: support undo/redo for ProEditor --- src/ProEditor/container/StoreUpdater.tsx | 16 +++++- src/ProEditor/hooks/useProEditor.ts | 7 ++- src/ProEditor/store/slices/config.ts | 34 ++++++++++--- src/ProEditor/store/slices/general.ts | 26 ++++++++-- src/ProEditor/utils/yjs.ts | 65 ++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 src/ProEditor/utils/yjs.ts diff --git a/src/ProEditor/container/StoreUpdater.tsx b/src/ProEditor/container/StoreUpdater.tsx index 4ac1be3d..ae3a7318 100644 --- a/src/ProEditor/container/StoreUpdater.tsx +++ b/src/ProEditor/container/StoreUpdater.tsx @@ -1,3 +1,4 @@ +import isEqual from 'fast-deep-equal'; import { memo, MutableRefObject, useImperativeHandle } from 'react'; import { createStoreUpdater } from 'zustand-utils'; @@ -27,9 +28,22 @@ const StoreUpdater = memo( }: StoreUpdaterProps) => { const storeApi = useStoreApi(); const useStoreUpdater = createStoreUpdater(storeApi); + const { yjsDoc } = storeApi.getState(); + + // 结合 yjs 进行变更 + const useUpdateWithYjs = (key: 'config', value: any) => { + useStoreUpdater(key, value, [value], (partialNewState) => { + // 如果相等,不需要更新 + if (isEqual(value, storeApi.getState()[key])) return; + + storeApi.setState(partialNewState); + + yjsDoc.updateHistoryData(partialNewState); + }); + }; useStoreUpdater('mode', mode); - useStoreUpdater('config', config); + useUpdateWithYjs('config', config); useStoreUpdater('assetAwareness', assetAwareness); useStoreUpdater('editorAwareness', editorAwareness); diff --git a/src/ProEditor/hooks/useProEditor.ts b/src/ProEditor/hooks/useProEditor.ts index 23d58e5f..60041329 100644 --- a/src/ProEditor/hooks/useProEditor.ts +++ b/src/ProEditor/hooks/useProEditor.ts @@ -15,7 +15,8 @@ export interface ProEditorInstance extends PublicProE export const useProEditor = (): ProEditorInstance => { const storeApi = useStoreApi(); - const { deselectCanvas, updateConfig, exportConfig, resetConfig } = storeApi.getState(); + const { deselectCanvas, updateConfig, exportConfig, resetConfig, undo, redo } = + storeApi.getState(); const getViewport = useMemoizedFn(() => storeApi.getState().editorAwareness.viewport); const getEditorAwareness = useMemoizedFn(() => storeApi.getState().editorAwareness); @@ -28,11 +29,13 @@ export const useProEditor = (): ProEditorInstance => { deselectCanvas, exportConfig, resetConfig, + undo, + redo, getViewport, getConfig, getProps, getEditorAwareness, }), - [updateConfig, deselectCanvas, exportConfig], + [], ); }; diff --git a/src/ProEditor/store/slices/config.ts b/src/ProEditor/store/slices/config.ts index aebf0877..6463ef22 100644 --- a/src/ProEditor/store/slices/config.ts +++ b/src/ProEditor/store/slices/config.ts @@ -1,7 +1,9 @@ +import merge from 'lodash.merge'; import { StateCreator } from 'zustand'; import { ComponentAsset } from '@/ComponentAsset'; +import { DocWithHistoryManager, UserActionParams } from '../../utils/yjs'; import { InternalProEditorStore } from '../createStore'; // ======== state ======== // @@ -30,6 +32,7 @@ export interface ConfigPublicState { export interface ConfigSliceState extends ConfigPublicState { /** 组件的 props */ props?: any; + yjsDoc: DocWithHistoryManager<{ config: any }>; } const initialConfigState: ConfigSliceState = { @@ -40,24 +43,35 @@ const initialConfigState: ConfigSliceState = { config: null, onConfigChange: null, props: {}, + yjsDoc: new DocWithHistoryManager<{ config: any }>(), }; // ======== action ======== // +export interface ActionPayload { + type: string; + payload: any; +} + +export interface ActionOptions { + recordHistory?: boolean; + payload?: Partial; +} + export interface ConfigPublicAction { /** * 导出配置 */ exportConfig: () => void; resetConfig: () => void; - updateConfig: (config: Partial) => void; + updateConfig: (config: Partial, options?: ActionOptions) => void; } export interface ConfigSlice extends ConfigPublicAction, ConfigSliceState { /** * 内部更新配置 **/ - internalUpdateConfig: (config: Partial) => void; + internalUpdateConfig: (config: Partial, payload?: ActionPayload) => void; } export const configSlice: StateCreator< @@ -74,12 +88,12 @@ export const configSlice: StateCreator< * 内部修改 config 方法 * 传给 ProTableStore 进行 config 同步 */ - internalUpdateConfig: (config) => { + internalUpdateConfig: (config, payload) => { const { onConfigChange, componentAsset } = get(); - const nextConfig = { ...get().config, ...config }; + const nextConfig = merge({}, get().config, config); - set({ config: nextConfig }, false, '🕹内部更新:config'); + set({ config: nextConfig }, false, payload); onConfigChange?.({ config: nextConfig, @@ -99,7 +113,13 @@ export const configSlice: StateCreator< document.body.removeChild(eleLink); }, - updateConfig: (config) => { - get().internalUpdateConfig(config); + updateConfig: (config, action) => { + get().internalUpdateConfig(config, { type: '外部 updateConfig 更新', payload: config }); + + const useAction = merge({}, { recordHistory: true }, action); + + if (useAction.recordHistory) { + get().yjsDoc.recordHistoryData({ config }, { ...useAction.payload, timestamp: Date.now() }); + } }, }); diff --git a/src/ProEditor/store/slices/general.ts b/src/ProEditor/store/slices/general.ts index 80cdf9a3..a50df8fd 100644 --- a/src/ProEditor/store/slices/general.ts +++ b/src/ProEditor/store/slices/general.ts @@ -30,8 +30,10 @@ const initialGeneralState: GeneralSliceState = { // ======== action ======== // -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GeneralPublicAction {} +export interface GeneralPublicAction { + undo: () => void; + redo: () => void; +} export interface GeneralSlice extends GeneralPublicAction, GeneralSliceState {} @@ -40,6 +42,24 @@ export const generalSlice: StateCreator< [['zustand/devtools', never]], [], GeneralSlice -> = () => ({ +> = (set, get) => ({ ...initialGeneralState, + + undo: () => { + const { yjsDoc, internalUpdateConfig } = get(); + const stack = yjsDoc.undo(); + + const { config } = yjsDoc.getHistoryJSON(); + + internalUpdateConfig(config, { type: 'history/undo', payload: stack }); + }, + redo: () => { + const { yjsDoc, internalUpdateConfig } = get(); + + const stack = yjsDoc.redo(); + + const { config } = yjsDoc.getHistoryJSON(); + + internalUpdateConfig(config, { type: 'history/redo', payload: stack }); + }, }); diff --git a/src/ProEditor/utils/yjs.ts b/src/ProEditor/utils/yjs.ts new file mode 100644 index 00000000..44304831 --- /dev/null +++ b/src/ProEditor/utils/yjs.ts @@ -0,0 +1,65 @@ +import { Doc, UndoManager } from 'yjs'; +import { DocOpts } from 'yjs/dist/src/utils/Doc'; + +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; + } +} + +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]), + }); + } + + 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(); + }; +}