Selaa lähdekoodia

feat: updating history

lianghongjie 1 vuosi sitten
vanhempi
commit
16c0793c66

+ 1 - 0
package.json

@@ -49,6 +49,7 @@
     "swiper": "^8.4.4",
     "three": "^0.146.0",
     "vue": "^3.2.45",
+    "vue-dndrop": "^1.3.1",
     "vue-router": "^4.0.3",
     "vue-types": "^4.2.1"
   },

+ 7 - 2
src/modules/editor/actions.ts

@@ -8,8 +8,8 @@ export const actions = EditorModule.action({
     this.store.initDesignData(tempData);
   },
   // 切换模式
-  switchEditMode(v: string) {
-    this.store.setEditMode(v);
+  switchMode(v: string) {
+    this.store.setMode(v);
   },
   // 添加组件到画布
   addCompToDesign(compKey: ICompKeys) {
@@ -28,4 +28,9 @@ export const actions = EditorModule.action({
     }
     this.store.deleteComp(compId);
   },
+  // 移动组件
+  moveComp(selIndex: number, targetIndex: number) {
+    if (selIndex === targetIndex) return;
+    this.store.moveComp(selIndex, targetIndex);
+  },
 });

+ 27 - 0
src/modules/editor/actions/edit.ts

@@ -0,0 +1,27 @@
+import { EditorModule } from "..";
+import { ICompKeys } from "../typings";
+
+export const editActions = EditorModule.action({
+  // 添加组件到画布
+  addCompToDesign(compKey: ICompKeys) {
+    const designComp = this.store.insertDesignContent(compKey);
+    this.actions.pickCurrComp(designComp.id);
+  },
+  // 切换当前组件
+  pickCurrComp(compId: string) {
+    if (compId === this.store.currCompId) return;
+    this.store.setCurrComp(compId);
+  },
+  // 删除组件
+  removeComp(compId: string) {
+    if (compId === this.store.currCompId) {
+      this.store.currCompId = "";
+    }
+    this.store.deleteComp(compId);
+  },
+  // 移动组件
+  moveComp(selIndex: number, targetIndex: number) {
+    if (selIndex === targetIndex) return;
+    this.store.moveComp(selIndex, targetIndex);
+  },
+});

+ 13 - 0
src/modules/editor/actions/init.ts

@@ -0,0 +1,13 @@
+import { EditorModule } from "..";
+import { DesignTemp } from "../defines/DesignTemp";
+
+export const initActions = EditorModule.action({
+  // 初始化数据
+  initData(tempData: DesignTemp) {
+    this.store.initDesignData(tempData);
+  },
+  // 切换模式
+  switchMode(v: string) {
+    this.store.setMode(v);
+  },
+});

+ 1 - 1
src/modules/editor/components/CompUI/baseUI/Image.tsx

@@ -21,7 +21,7 @@ export const Image = defineComponent({
         <img
           class="w-1/1 h-1/1"
           src={props.value || imgDef}
-          onClick={store.editMode === "edit" ? changeVal : undefined}
+          onClick={store.isEditMode? changeVal : undefined}
         />
       </View>
     );

+ 1 - 1
src/modules/editor/components/CompUI/baseUI/Text.tsx

@@ -23,7 +23,7 @@ export const Text = defineComponent({
 
     return () => (
       <View class={textStyle}>
-        {store.editMode === "edit" ? (
+        {store.isEditMode ? (
           <textarea
             ref={textRef}
             value={props.value}

+ 2 - 2
src/modules/editor/components/CompUI/baseUI/Textarea.tsx

@@ -18,7 +18,7 @@ export const Textarea = defineComponent({
       quill = new Quill(domRef.value, {
         theme: "bubble",
       });
-      store.editMode !== "edit" && quill.disable();
+      store.isEditMode && quill.disable();
       quill.on("text-change", () => {
         emit("update:value", quill?.getContents());
       });
@@ -29,7 +29,7 @@ export const Textarea = defineComponent({
       }
     });
     watchEffect(() => {
-      if (store.editMode === "edit") {
+      if (store.isEditMode) {
         quill?.enable();
       } else {
         quill?.disable();

+ 16 - 3
src/modules/editor/components/CompUI/baseUI/View.tsx

@@ -15,18 +15,18 @@ export const View = defineComponent({
     });
     return () => {
       const isComp = state.compId;
-      const isEdit = store.editMode === "edit";
-      const isSelected = isEdit && store.currCompId === state.compId;
+      const isSelected = store.isEditMode && store.currCompId === state.compId;
 
       return (
         <div
           ref={viewRef}
           class={
-            isEdit && [
+            store.isEditMode && [
               isComp ? viewStyle : "view_inside",
               isSelected && "view_selected",
             ]
           }
+          draggable={isSelected ? true : false}
           onClick={
             state.compId ? () => actions.pickCurrComp(state.compId) : undefined
           }
@@ -39,6 +39,7 @@ export const View = defineComponent({
 });
 
 const viewStyle = css`
+  position: relative;
   &:hover {
     outline: 1px dashed @inf-primary-color;
   }
@@ -61,4 +62,16 @@ const viewStyle = css`
       outline-offset: -1px;
     }
   }
+
+  .view_drager {
+    position: absolute;
+    right: 0;
+    bottom: 0;
+    width: 30px;
+    height: 30px;
+    background-color: @inf-primary-color;
+    transform: translateY(100%);
+    text-align: center;
+    z-index: 1;
+  }
 `;

+ 48 - 20
src/modules/editor/components/Viewport/Canvas/index.tsx

@@ -2,11 +2,12 @@ import { useEditor } from "@/modules/editor";
 import { HotKeyCtrl } from "@/modules/editor/controllers/HotKeyCtrl";
 import { defineUI } from "queenjs";
 import { onUnmounted } from "vue";
+import { Container, Draggable } from "vue-dndrop";
 
 export default defineUI({
   setup() {
     const editor = useEditor();
-    const { store, components } = editor;
+    const { store, components, actions } = editor;
 
     const hotKeyCtrl = new HotKeyCtrl(editor);
     hotKeyCtrl.init();
@@ -14,25 +15,52 @@ export default defineUI({
       hotKeyCtrl.destroy();
     });
 
-    return () => (
-      <div class="h-1/1 text-center">
-        <div
-          class="inline-block w-375px h-750px overflow-y-auto scrollbar"
-          style={store.designData.pageStyle}
-        >
-          {store.designData.content.map((d) => {
-            const Comp = components.CompUI[d.compKey];
-            return (
-              <Comp
-                data-id={d.id}
-                {...(d.props || {})}
-                style={d.style}
-                v-model={[d.value, "value"]}
-              />
-            );
-          })}
+    return () => {
+      const { content } = store.designData;
+      return (
+        <div class="h-1/1 text-center">
+          <div
+            class="inline-block w-375px h-750px overflow-y-auto scrollbar"
+            style={store.designData.pageStyle}
+          >
+            {store.isEditMode ? (
+              <Container
+                onDrop={(e: any) =>
+                  actions.moveComp(e.removedIndex, e.addedIndex)
+                }
+              >
+                {content.map((d) => {
+                  const Comp = components.CompUI[d.compKey];
+                  return (
+                    <Draggable key={d.id}>
+                      <Comp
+                        class="draggable-item"
+                        data-id={d.id}
+                        {...(d.props || {})}
+                        style={d.style}
+                        v-model={[d.value, "value"]}
+                      />
+                    </Draggable>
+                  );
+                })}
+              </Container>
+            ) : (
+              content.map((d) => {
+                const Comp = components.CompUI[d.compKey];
+                return (
+                  <Comp
+                    key={d.id}
+                    data-id={d.id}
+                    {...(d.props || {})}
+                    style={d.style}
+                    v-model={[d.value, "value"]}
+                  />
+                );
+              })
+            )}
+          </div>
         </div>
-      </div>
-    );
+      );
+    };
   },
 });

+ 2 - 2
src/modules/editor/components/Viewport/Header/index.tsx

@@ -8,8 +8,8 @@ export default defineUI({
     return () => (
       <div class="text-center">
         <Radio.Group
-          value={store.editMode}
-          onChange={(e) => actions.switchEditMode(e.target.value)}
+          value={store.mode}
+          onChange={(e) => actions.switchMode(e.target.value)}
         >
           <Radio.Button value="edit">编辑</Radio.Button>
           <Radio.Button value="preview">预览</Radio.Button>

+ 269 - 0
src/modules/editor/controllers/HistoryCtrl/HistoryController.ts

@@ -0,0 +1,269 @@
+import { cloneDeep, get } from "lodash";
+import { StateRoot } from "queenjs";
+
+class State extends StateRoot {
+  lenth = 0; //操作栈的长度
+  total = 50; //操作栈总长度
+  cap = 100; //操作栈的容量
+
+  opIndex = -1; //操作栈的指针
+  saveIndex = -1; //保存的指针
+  enable = true;
+
+  canSave = this.computed((state) => {
+    return state.opIndex != state.saveIndex;
+  });
+  canUndo = this.computed((state) => {
+    return state.enable && state.opIndex >= 0;
+  });
+  canRedo = this.computed((state) => {
+    return state.enable && state.opIndex + 1 < state.lenth;
+  });
+}
+
+export class HistoryController {
+  combine = false;
+  _group = false;
+  state = new State().reactive();
+  queue: Action[] = [];
+  groupQueue: Action[] = [];
+
+  constructor(cap = 100) {
+    this.state.cap = 2 * cap;
+
+    //最大缓存行为个数
+    this.state.total = cap;
+  }
+
+  saved() {
+    this.state.saveIndex = this.state.opIndex;
+  }
+
+  record(action: Action) {
+    if (!action) return;
+    if (this.group) {
+      this.groupQueue.push(action);
+      return;
+    }
+    const state = this.state;
+    if (!state.enable) return;
+    const lastAction = this.queue[state.opIndex];
+
+    // 同时满足[可合并状态|操作指针指向栈顶|同对象的同种操作]
+    if (
+      lastAction?.combine &&
+      this.queue.length === state.opIndex + 1 &&
+      action.name === lastAction.name &&
+      action.root === lastAction.root
+    ) {
+      lastAction.value = action.value;
+      lastAction.combine = action.combine;
+      return;
+    }
+
+    let index = state.opIndex + 1;
+    if (index >= state.cap) {
+      //大于容量了
+      this.queue = this.queue.slice(state.total);
+      index = this.state.total;
+    }
+
+    this.state.opIndex = index;
+    this.queue[index] = action;
+    this.state.lenth = index + 1;
+
+    if (this.queue.length > index + 1) {
+      //丢掉回退的部分操作
+      this.queue.splice(index + 1, this.queue.length - index - 1);
+    }
+  }
+
+  undo() {
+    const state = this.state;
+    if (!state.enable) return;
+
+    if (state.opIndex < 0) return; //已经退到第一步了
+
+    const action = this.queue[state.opIndex];
+    action.undo();
+
+    state.opIndex = state.opIndex - 1;
+  }
+
+  redo() {
+    const state = this.state;
+    if (!state.enable) return;
+
+    if (state.opIndex >= this.queue.length - 1) return; //已经是最后一步操作了
+
+    const action = this.queue[state.opIndex + 1];
+    action.redo();
+
+    state.opIndex = state.opIndex + 1;
+  }
+
+  get group() {
+    return this._group;
+  }
+  set group(state) {
+    this._group = state;
+    if (!state) {
+      if (this.groupQueue.length) {
+        this.record(
+          this.groupQueue.length == 1
+            ? this.groupQueue[0]
+            : (new GroupAction(this.groupQueue) as any)
+        );
+      }
+    }
+    this.groupQueue = [];
+  }
+
+  removeHead() {
+    const state = this.state;
+    if (state.opIndex > 0) {
+      state.opIndex = state.opIndex - 1;
+      this.queue.splice(this.queue.length - 1, 1);
+    }
+  }
+
+  //清除操作
+  clear() {
+    const len = this.state.opIndex - this.state.saveIndex;
+    if (len !== 0) {
+      Array.from({ length: Math.abs(len) }).forEach(() => {
+        len > 0 ? this.undo() : this.redo();
+      });
+    }
+    this.queue = [];
+    this.state.opIndex = -1;
+    this.state.lenth = 0;
+    this.state.saveIndex = -1;
+  }
+}
+
+export class Action {
+  combine = false;
+  name: string;
+  root: any;
+  value: any;
+  valueOld: any;
+
+  constructor(root: any, name: string, value?: any) {
+    const [, path] = name.split(":");
+    this.name = name;
+    this.root = root;
+    this.valueOld = get(root, path.split("."));
+    this.value = value;
+  }
+
+  async redo() {
+    this._action("redo", this.value);
+  }
+
+  async undo() {
+    this._action("undo", this.valueOld);
+  }
+
+  _action(actionType: "redo" | "undo", value: any) {
+    const [, type, , , parentPath = "", attrName = ""] =
+      this.name.match(/^(.+):(((.+)\.)?(.+))?$/) || [];
+    let paths: string | string[] = parentPath.split(".");
+    if (type == "add" && attrName) {
+      if (parentPath) {
+        paths.push(attrName);
+      } else {
+        paths = attrName;
+      }
+    }
+    const parent = get(this.root, paths) || this.root;
+    switch (type) {
+      case "add":
+        if (actionType === "redo") {
+          parent.push(value);
+        } else {
+          parent.pop();
+        }
+        break;
+      case "set":
+        parent[attrName] = value;
+        break;
+      case "remove":
+        if (parent instanceof Array) {
+          if (actionType === "redo") {
+            parent.splice(+attrName, 1);
+          } else {
+            parent.splice(+attrName, 0, value);
+          }
+        } else {
+          if (actionType === "redo") {
+            delete parent[attrName];
+          } else {
+            parent[attrName] = value;
+          }
+        }
+        break;
+    }
+  }
+}
+
+export class GroupAction {
+  group: Action[];
+  constructor(group: Action[]) {
+    this.group = group;
+  }
+  undo() {
+    this.group.reverse().forEach((d) => d.undo());
+  }
+  redo() {
+    this.group.forEach((d) => d.redo());
+  }
+}
+
+type IOptions = { combine?: boolean };
+
+export class OperationController {
+  op: (
+    type: "add" | "set" | "remove",
+    path: string,
+    value?: any,
+    options?: IOptions
+  ) => void;
+  constructor(root: () => any, history: () => HistoryController) {
+    this.op = (type, path, value, options) => {
+      const action = new Action(root(), `${type}:${path}`, cloneDeep(value));
+      action.combine = options?.combine || false;
+      history().record(action);
+      action.redo();
+    };
+  }
+  add(path: string, value: any, options?: IOptions) {
+    this.op("add", path, value, options);
+  }
+  set(path: string, value: any, options?: IOptions) {
+    this.op("set", path, value, options);
+  }
+  remove(path: string, options?: IOptions) {
+    this.op("remove", path, undefined, options);
+  }
+}
+
+type Fn<T> = () => T;
+export class HistoryCtrl {
+  op: <T>(data: T | Fn<T>) => OperationController;
+  history: HistoryController;
+  opStatus = false;
+
+  constructor(historyTotal = 50) {
+    this.history = new HistoryController(historyTotal);
+    this.op = (data) => {
+      let getPath;
+      if (data instanceof Function) {
+        getPath = data;
+      } else {
+        getPath = () => data;
+      }
+      return new OperationController(getPath, () => this.history);
+    };
+  }
+}

+ 5 - 0
src/modules/editor/controllers/HistoryCtrl/createProxyStore.ts

@@ -0,0 +1,5 @@
+import { HistoryController } from "./HistoryController";
+
+export function createProxyStore(store: any, history: HistoryController) {
+  return store;
+}

+ 48 - 0
src/modules/editor/controllers/HistoryCtrl/index.ts

@@ -0,0 +1,48 @@
+import { AnyFun } from "queenjs/typing";
+import { EditorModule } from "../..";
+import { HistoryController } from "./HistoryController";
+import { createProxyStore } from "./createProxyStore";
+
+export class HistoryCtrl {
+  history: HistoryController;
+  opStatus = false;
+  proxyStore: any;
+
+  constructor(protected module: EditorModule, historyTotal = 50) {
+    this.history = new HistoryController(historyTotal);
+    this.proxyStore = createProxyStore(module.store, this.history);
+  }
+
+  applyActions(actNames: string[]) {
+    const actions: any = this.module.actions;
+    actNames.forEach((actName) => {
+      const action = actions[actName];
+      actions[actName] = proxyAction.bind(this, action);
+    });
+  }
+}
+
+async function proxyAction(this: HistoryCtrl, action: AnyFun, ...args: any[]) {
+  if (this.opStatus) {
+    return await action(...args);
+  }
+  const { module, proxyStore, history } = this;
+  const { store } = module;
+  try {
+    module.store = proxyStore;
+    history.group = true;
+    await action(...args);
+    history.group = false;
+  } catch (error) {
+    if (history.groupQueue.length) {
+      history.group = false;
+      history.undo();
+      history.queue.pop();
+      history.state.lenth--;
+    } else {
+      history.group = false;
+    }
+  } finally {
+    module.store = store;
+  }
+}

+ 12 - 4
src/modules/editor/index.ts

@@ -1,16 +1,24 @@
 import { ModuleRoot } from "queenjs";
-import { actions } from "./actions";
 import components from "./components";
 import config from "./config";
 import { store } from "./stores";
+import { HistoryCtrl } from "./controllers/HistoryCtrl";
+import { initActions } from "./actions/init";
+import { editActions } from "./actions/edit";
 
 export class EditorModule extends ModuleRoot {
   config = this.setConfig(config);
-
-  actions = this.createActions(actions);
+  components = this.useComponents(components);
+  
+  actions = this.createActions([initActions, editActions]);
   store = this.createStore(store);
 
-  components = this.useComponents(components);
+
+  historyCtrl = new HistoryCtrl(this);
+
+  onReady() {
+    this.historyCtrl.applyActions(Object.keys(editActions));
+  }
 }
 
 export const { useEditor, initEditor } = EditorModule.hook("Editor");

+ 11 - 3
src/modules/editor/stores/index.ts

@@ -6,11 +6,14 @@ import { ICompKeys } from "../typings";
 
 export const store = EditorModule.store({
   state: () => ({
-    editMode: "edit",
+    mode: "edit",
     currCompId: "",
     designData: new DesignTemp(),
   }),
   getters: {
+    isEditMode(state) {
+      return state.mode === "edit";
+    },
     currComp(state) {
       const comp = state.designData.content.find(
         (d) => d.id === state.currCompId
@@ -19,8 +22,8 @@ export const store = EditorModule.store({
     },
   },
   actions: {
-    setEditMode(v: string) {
-      this.store.editMode = v;
+    setMode(v: string) {
+      this.store.mode = v;
     },
     initDesignData(data: Partial<DesignTemp>) {
       this.store.designData = new DesignTemp(data);
@@ -45,5 +48,10 @@ export const store = EditorModule.store({
         this.store.designData.content.splice(index, 1);
       }
     },
+    moveComp(selIndex: number, targetIndex: number) {
+      const { content } = this.store.designData;
+      const [selComp] = content.splice(selIndex, 1);
+      content.splice(targetIndex, 0, selComp);
+    },
   },
 });

+ 6 - 1
src/typings/pro.d.ts

@@ -13,4 +13,9 @@ declare type TableListResult<T> = Promise<{
     size: number;
     total: number;
   };
-}>;
+}>;
+
+declare module "vue-dndrop" {
+  export const Container: any;
+  export const Draggable: any;
+}

+ 5 - 0
yarn.lock

@@ -7606,6 +7606,11 @@ vue-demi@*:
   resolved "http://124.70.149.18:4873/vue-demi/-/vue-demi-0.14.1.tgz#1ed9af03a27642762bfed83d8750805302d0398d"
   integrity sha512-rt+yuCtXvscYot9SQQj3WKZJVSriPNqVkpVBNEHPzSgBv7QIYzsS410VqVgvx8f9AAPgjg+XPKvmV3vOqqkJQQ==
 
+vue-dndrop@^1.3.1:
+  version "1.3.1"
+  resolved "http://124.70.149.18:4873/vue-dndrop/-/vue-dndrop-1.3.1.tgz#b1e390ad2fae0dbd2c2d6cdd7bc46f679dfa4420"
+  integrity sha512-Pcvsu4RJG9dXiHvBO2sDFzDG+3PnlWt4Qu4QyCgg0MlCoHk8DUqEQt4wzn3G9g0mxdQfraUvdEQ0TDXCCbaKZw==
+
 vue-eslint-parser@^8.0.0, vue-eslint-parser@^8.0.1:
   version "8.3.0"
   resolved "http://124.70.149.18:4873/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz#5d31129a1b3dd89c0069ca0a1c88f970c360bd0d"