فهرست منبع

编辑器调整

zhangjie 3 سال پیش
والد
کامیت
794f0c5097
34فایلهای تغییر یافته به همراه1609 افزوده شده و 82 حذف شده
  1. BIN
      public/img/editor/answer_point_x.png
  2. BIN
      public/img/editor/menu_audio.png
  3. BIN
      public/img/editor/menu_bold.png
  4. BIN
      public/img/editor/menu_formula.png
  5. BIN
      public/img/editor/menu_image.png
  6. BIN
      public/img/editor/menu_italic.png
  7. BIN
      public/img/editor/menu_subscript.png
  8. BIN
      public/img/editor/menu_superscript.png
  9. BIN
      public/img/editor/menu_underline.png
  10. BIN
      public/img/editor/text_audio.png
  11. 40 39
      public/index.html
  12. 31 0
      src/components/RichText.vue
  13. 257 0
      src/components/vEditor/VEditor.vue
  14. 281 0
      src/components/vEditor/clipboard.js
  15. 151 0
      src/components/vEditor/components/FormulaDialog.vue
  16. 183 0
      src/components/vEditor/components/VMenu.vue
  17. 74 0
      src/components/vEditor/components/answerPoint.js
  18. 35 0
      src/components/vEditor/components/audio.js
  19. 66 0
      src/components/vEditor/components/changeMode.js
  20. BIN
      src/components/vEditor/components/horn.png
  21. 55 0
      src/components/vEditor/components/image.js
  22. BIN
      src/components/vEditor/components/upload_icon.png
  23. 5 0
      src/components/vEditor/constants.js
  24. 11 0
      src/components/vEditor/index.d.ts
  25. 133 0
      src/components/vEditor/renderJSON.js
  26. 166 0
      src/components/vEditor/toJSON.js
  27. 91 0
      src/components/vEditor/utils.js
  28. 3 5
      src/modules/questions/views/EditOtherQuestion.vue
  29. 6 9
      src/modules/questions/views/EditPaper.vue
  30. 5 10
      src/modules/questions/views/EditPaperPendingTrial.vue
  31. 4 4
      src/modules/questions/views/EditSelectQuestion.vue
  32. 3 7
      src/modules/questions/views/InsertBluePaperStructure.vue
  33. 3 7
      src/modules/questions/views/InsertPaperStructure.vue
  34. 6 1
      src/plugins/globalVuePlugins.js

BIN
public/img/editor/answer_point_x.png


BIN
public/img/editor/menu_audio.png


BIN
public/img/editor/menu_bold.png


BIN
public/img/editor/menu_formula.png


BIN
public/img/editor/menu_image.png


BIN
public/img/editor/menu_italic.png


BIN
public/img/editor/menu_subscript.png


BIN
public/img/editor/menu_superscript.png


BIN
public/img/editor/menu_underline.png


BIN
public/img/editor/text_audio.png


+ 40 - 39
public/index.html

@@ -1,39 +1,40 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8" />
-    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
-    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
-    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
-    <title>题库</title>
-    <script>
-      var _hmt = _hmt || [];
-
-      if (navigator.appVersion.includes("Chrome/")) {
-        try {
-          _hmt.push([
-            "_setUserTag",
-            "4873",
-            navigator.appVersion.match(/(Chrome\/\d\d)/)[0],
-          ]);
-        } catch (e) {
-          _hmt.push(["_setUserTag", "4873", navigator.appVersion]);
-        }
-      }
-
-      _hmt.push(["_setUserTag", "4889", navigator.appVersion]);
-      _hmt.push(["_setUserTag", "4894", navigator.userAgent]);
-    </script>
-    <script src="<%= BASE_URL %>ckeditor/ckeditor.js"></script>
-  </head>
-  <body>
-    <noscript>
-      <strong
-        >We're sorry but vue-starter doesn't work properly without JavaScript
-        enabled. Please enable it to continue.</strong
-      >
-    </noscript>
-    <div id="app"></div>
-    <!-- built files will be auto injected -->
-  </body>
-</html>
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="utf-8" />
+  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+  <meta name="viewport" content="width=device-width,initial-scale=1.0" />
+  <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
+  <title>题库</title>
+  <script>
+    var _hmt = _hmt || [];
+
+    if (navigator.appVersion.includes("Chrome/")) {
+      try {
+        _hmt.push([
+          "_setUserTag",
+          "4873",
+          navigator.appVersion.match(/(Chrome\/\d\d)/)[0],
+        ]);
+      } catch (e) {
+        _hmt.push(["_setUserTag", "4873", navigator.appVersion]);
+      }
+    }
+
+    _hmt.push(["_setUserTag", "4889", navigator.appVersion]);
+    _hmt.push(["_setUserTag", "4894", navigator.userAgent]);
+  </script>
+  <!-- <script src="<%= BASE_URL %>ckeditor/ckeditor.js"></script> -->
+</head>
+
+<body>
+  <noscript>
+    <strong>We're sorry but vue-starter doesn't work properly without JavaScript
+      enabled. Please enable it to continue.</strong>
+  </noscript>
+  <div id="app"></div>
+  <!-- built files will be auto injected -->
+</body>
+
+</html>

+ 31 - 0
src/components/RichText.vue

@@ -0,0 +1,31 @@
+<template>
+  <div class="rich-text"></div>
+</template>
+
+<script>
+import { renderRichText } from "./vEditor/renderJSON";
+
+export default {
+  name: "RichText",
+  props: {
+    textJson: {
+      type: Object,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {};
+  },
+  watch: {
+    textJson(val) {
+      renderRichText(val, this.$el);
+    },
+  },
+  mounted() {
+    renderRichText(this.textJson, this.$el);
+  },
+  methods: {},
+};
+</script>

+ 257 - 0
src/components/vEditor/VEditor.vue

@@ -0,0 +1,257 @@
+<template>
+  <div class="v-editor">
+    <VMenu class="v-editor-head" @audio-added="emitJSON" />
+    <div
+      :id="'ved' + _uid"
+      ref="editor"
+      class="v-editor-body"
+      :data-placeholder="placeholder"
+      contenteditable
+      :style="styles"
+      @input="emitJSON"
+    ></div>
+  </div>
+</template>
+
+<script>
+import VMenu from "./components/VMenu.vue";
+import { renderRichText } from "./renderJSON";
+import { toJSON } from "./toJSON";
+import {
+  IMAGE_EXCEED_SIZE_AS_ATTACHMENT,
+  JSON_EXCEED_SIZE_AS_ATTACHMENT,
+  MAX_AUDIO_SIZE,
+  MAX_IMAGE_SIZE,
+  MAX_JSON_SIZE,
+} from "./constants";
+import { pasteHandle } from "./clipboard";
+import { answerPointDragHandle } from "./components/answerPoint";
+import timeMixin from "@/mixins/timeMixin";
+
+export default {
+  name: "VEditor",
+  components: {
+    VMenu,
+  },
+  mixins: [timeMixin],
+  props: {
+    placeholder: { type: String, default: "请输入..." },
+    value: {
+      type: String,
+      // 要么为null,要么一定要遵循结构 body.sections[]
+      // const EMPTY_RICH_TEXT
+      default: () => JSON.stringify({ sections: [] }),
+    },
+    styles: { type: String, default: "" },
+    folder: { type: String, default: "" },
+    enableAnswerPoint: { type: Boolean, default: false },
+    maxAudioSize: { type: Number, default: MAX_AUDIO_SIZE, required: false },
+    maxImageSize: { type: Number, default: MAX_IMAGE_SIZE, required: false },
+    imageExceedSizeAsAttachment: {
+      type: Number,
+      default: IMAGE_EXCEED_SIZE_AS_ATTACHMENT,
+      required: false,
+    },
+    jsonExceedSizeAsAttachment: {
+      type: Number,
+      default: JSON_EXCEED_SIZE_AS_ATTACHMENT,
+      required: false,
+    },
+    maxJsonSize: { type: Number, default: MAX_JSON_SIZE, required: false },
+    emitType: {
+      type: String,
+      default: "json",
+    },
+  },
+  data() {
+    return {};
+  },
+  watch: {
+    value(val, oldVal) {
+      if (val !== oldVal) {
+        if (this.$refs.editor !== document.activeElement) {
+          this.initData();
+        }
+      }
+    },
+  },
+  mounted() {
+    this.initData();
+    this.$refs.editor.addEventListener("paste", pasteHandle.bind(this));
+    this.$refs.editor.addEventListener(
+      "input",
+      answerPointDragHandle.bind(this)
+    );
+    this.$refs.editor.addEventListener("wheel", this.wheelEventHandle);
+  },
+  beforeDestroy() {
+    this.clearSetTs();
+  },
+  methods: {
+    initData() {
+      if (this.emitType === "html") {
+        this.$refs.editor.innerHTML = this.value;
+      } else {
+        let content = { sections: [] };
+        try {
+          content = JSON.parse(this.value);
+          renderRichText(content, this.$refs.editor, false);
+        } catch (e) {
+          this.$refs.editor.innerHTML = this.value;
+          this.emitJSON();
+        }
+      }
+    },
+    emitJSON() {
+      if (!this.$refs.editor.contentEditable) {
+        // 不是出于contentEditable则不更新
+        return;
+      }
+
+      if (this.emitType === "html") {
+        const content = this.$refs.editor.innerHTML;
+        this.$emit("input", content);
+        this.$emit("change", content);
+        return;
+      }
+
+      this.clearSetTs();
+      // 延迟触发input任务,避免频繁触发,同时也等待图片渲染,方便获取图片显示尺寸
+      this.addSetTime(() => {
+        console.log("input:" + Math.random());
+        this.inputDelaying = false;
+        const json = toJSON(this.$refs.editor);
+        // console.log("emit", json, this.$refs.editor);
+        // 未来校验多了的话,考虑移除去
+        if (json.length > this.maxJsonSize) {
+          this.$message.error(
+            `富文本长度超过限制,最长可以为 ${
+              this.maxJsonSize / 1024 / 1024
+            } MB.`
+          );
+          return;
+        }
+        // console.log(json, this.value, json === this.value);
+        this.$emit("input", json);
+        this.$emit("change", json);
+        // this.$emit("on-result", json);
+        // console.log(json);
+      }, 200);
+
+      // re-render 之后 restore cursor? 不现实,因为dom结构变了。
+    },
+    wheelEventHandle(e) {
+      // console.log(e);
+      // console.dir(e.target);
+      const el = e.target;
+      if (el.tagName && el.tagName === "IMG") {
+        e.preventDefault();
+        e.stopPropagation();
+        const shift = e.deltaY > 0;
+        const newWidth =
+          +getComputedStyle(el).width.replace("px", "") + (shift ? 1 : -1);
+        // console.log(newWidth, el.naturalWidth);
+        if (newWidth >= 16 && newWidth <= el.naturalWidth) {
+          el.style.width = newWidth + "px";
+          el.style.height =
+            (newWidth / el.naturalWidth) * el.naturalHeight + "px";
+          // el.setAttribute("width", newWidth);
+
+          this.emitJSON();
+        }
+      }
+    },
+  },
+};
+</script>
+
+<style>
+.v-editor {
+  position: relative;
+  line-height: 20px;
+}
+.v-editor-head {
+  position: absolute;
+  left: 2px;
+  right: 2px;
+  background-color: #fff;
+  top: 2px;
+  z-index: 9;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.v-editor-body,
+.sourceView {
+  border: 1px solid #e4e7ed;
+  border-radius: 5px;
+  min-height: 100px;
+  max-height: 300px;
+  padding: 8px;
+  padding-top: 32px;
+  overflow: scroll;
+  outline: none;
+  white-space: pre;
+}
+
+.sourceView {
+  margin: -5px;
+}
+
+.v-editor-body[contenteditable="true"]:empty:not(:focus)::before {
+  content: attr(data-placeholder);
+  color: grey;
+}
+.v-editor-body:focus {
+  border-color: #1886fe;
+}
+
+.v-editor-body div {
+  min-height: 18px;
+  line-height: 20px;
+}
+
+.v-editor-body img {
+  max-width: 100%;
+}
+.v-editor-body img[data-is-answer-point] {
+  max-height: 16px;
+  display: inline-block;
+  vertical-align: text-top;
+}
+
+.v-editor-body img.audio {
+  height: 16px;
+  padding: 0 2px;
+  display: inline-block;
+  margin-bottom: 4px;
+}
+.v-editor-body img[data-is-image] {
+  /* max-height: 42px; */
+  display: inline-block;
+}
+
+.v-editor-body audio {
+  height: 16px;
+  width: 180px;
+  display: inline-block;
+  margin-bottom: -4px;
+}
+
+.v-editor-body ::-webkit-media-controls-mute-button {
+  display: none !important;
+}
+
+.v-editor-body ::-webkit-media-controls-volume-slider {
+  display: none !important;
+}
+
+.v-editor-body b {
+  font-weight: bold;
+}
+.v-editor-body i {
+  font-style: italic;
+}
+.v-editor-body u {
+  text-decoration: underline;
+}
+</style>

+ 281 - 0
src/components/vEditor/clipboard.js

@@ -0,0 +1,281 @@
+import { insertTagToEditor } from "./utils";
+// import { randomCode } from "../../utils/utils";
+
+let _this = null;
+
+export function dataUrlToBlob(base64Buf) {
+  console.log(base64Buf);
+  const bufs = base64Buf.split(".");
+  const mime = bufs[0].match(/:(.*?);/)[1];
+  const bstr = window.atob(bufs[1]);
+  const len = bstr.length;
+  const u8arr = new Uint8Array(len);
+  for (let i = 0; i < len; i++) {
+    u8arr[i] = bstr.charCodeAt(i);
+  }
+  return new Blob([u8arr], { type: mime });
+}
+
+function fileToBase64(file) {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.readAsDataURL(file);
+    reader.onload = () => resolve(reader.result);
+    reader.onerror = (error) => reject(error);
+  });
+}
+
+function getDataUrlFromImageUrl(url) {
+  const img = new Image();
+  img.setAttribute("crossorigin", "anonymous");
+  img.src = url;
+
+  return new Promise((resolve, reject) => {
+    const canvas = document.createElement("canvas");
+    const ctx = canvas.getContext("2d");
+    canvas.width = img.width;
+    canvas.height = img.height;
+    // console.dir(img);
+    img.onload = function () {
+      ctx.drawImage(img, 0, 0);
+      resolve(canvas.toDataURL("image/png"));
+    };
+    img.onerror = function (err) {
+      reject(err);
+    };
+  });
+}
+
+function dataItemGetAsString(dataTransferItem) {
+  return new Promise((resolve, reject) => {
+    try {
+      dataTransferItem.getAsString((s) => {
+        resolve(s);
+      });
+    } catch (error) {
+      reject(error);
+    }
+  });
+}
+
+// function checkHtmlIsFromOffice(htmlDom) {
+//   const meta = htmlDom.querySelector("meta[name='Originator']");
+//   return meta && meta.getAttribute("content").includes("Microsoft Word");
+// }
+
+function checkDomIsBlock(dom) {
+  if (dom.nodeType === Node.ELEMENT_NODE) {
+    const disPlay = window.getComputedStyle(dom).display;
+    if (disPlay) {
+      return disPlay === "block";
+    } else {
+      const blockDomNames = [
+        "div",
+        "p",
+        "h1",
+        "h2",
+        "h3",
+        "h4",
+        "h5",
+        "h6",
+        "ul",
+        "ol",
+        "table",
+        "tr",
+      ];
+      return blockDomNames.includes(dom.tagName.toLowerCase());
+    }
+  }
+}
+
+function checkDomHasTextContent(content) {
+  let cont = content.replace(/\n/g, "");
+  return !!cont;
+}
+
+function checkDomIsUnvalid(dom) {
+  const unvalidFunc = [
+    (node) =>
+      node.tagName &&
+      ["meta", "link", "script", "style", "title"].includes(
+        node.tagName.toLowerCase()
+      ),
+    (node) => node.nodeType === Node.COMMENT_NODE,
+  ];
+
+  return unvalidFunc.some((unfunc) => unfunc(dom));
+}
+
+function getHtmlValidDom(htmlDom, checkBlock) {
+  let doms = [];
+  let curBlockDoms = [];
+
+  const findDom = (nodeList) => {
+    nodeList.forEach((node) => {
+      if (checkDomIsUnvalid(node)) return;
+
+      if (checkBlock && checkDomIsBlock(node) && curBlockDoms.length) {
+        doms.push(curBlockDoms);
+        curBlockDoms = [];
+      }
+
+      if (node.childNodes.length) {
+        findDom(node.childNodes);
+        return;
+      }
+
+      if (
+        (node.tagName && node.tagName.toLowerCase() === "img") ||
+        (node.nodeType === Node.TEXT_NODE &&
+          checkDomHasTextContent(node.textContent))
+      ) {
+        curBlockDoms.push(node);
+      }
+    });
+  };
+
+  findDom(htmlDom.childNodes);
+
+  if (curBlockDoms.length) {
+    doms.push(curBlockDoms);
+    curBlockDoms = [];
+  }
+  console.log(doms);
+  return doms;
+}
+
+// function getOfficeHtmlValidDom(htmlDom) {
+//   let doms = [];
+//   htmlDom.querySelectorAll("p").forEach((pDom) => {
+//     const elems = getHtmlValidDom(pDom, false);
+//     if (elems.length) doms.push(...elems);
+//   });
+//   return doms;
+// }
+
+async function pasteHtmlItem(htmlItem) {
+  const cont = await dataItemGetAsString(htmlItem).catch((e) => {
+    console.log(e);
+  });
+  if (!cont) return;
+
+  const divDom = document.createElement("div");
+  divDom.innerHTML = cont;
+  console.log(divDom);
+
+  const groups = getHtmlValidDom(divDom, true);
+
+  for (let i = 0; i < groups.length; i++) {
+    for (let j = 0; j < groups[i].length; j++) {
+      const element = groups[i][j];
+      if (element.tagName && element.tagName.toLowerCase() === "img") {
+        await pasteImageDom(element);
+      } else {
+        document.execCommand("insertText", false, element.textContent);
+      }
+    }
+    document.execCommand("insertParagraph");
+  }
+}
+
+async function pasteImageDom(imgDom) {
+  console.log(imgDom);
+  const src = imgDom.getAttribute("src");
+  if (!src) return;
+  let attributes = {};
+  if (imgDom.getAttribute("width"))
+    attributes.width = imgDom.getAttribute("width");
+  if (imgDom.getAttribute("height"))
+    attributes.height = imgDom.getAttribute("height");
+
+  if (src.includes("base64")) {
+    insertTagToEditor(null, "IMG", src, attributes);
+  } else if (
+    src.includes("http://") ||
+    src.includes("https://") ||
+    src.includes("file:///")
+  ) {
+    const dataUrl = await getDataUrlFromImageUrl(src).catch(() => {});
+    // console.log(dataUrl);
+    if (!dataUrl) return;
+    insertTagToEditor(null, "IMG", dataUrl, attributes);
+  } else {
+    console.log("unknown image url");
+  }
+}
+
+async function pasteImage(file, attributes) {
+  if (file.size > _this.maxImageSize) {
+    _this.$message(`单张图片超过限制,最大为 ${_this.maxImageSize / 1024} KB.`);
+    return;
+  }
+
+  // 默认转base64;
+  const srcBase64 = await fileToBase64(file);
+  insertTagToEditor(null, "IMG", srcBase64, attributes);
+}
+
+async function pasteText(textItem) {
+  const cont = await dataItemGetAsString(textItem).catch((e) => {
+    console.log(e);
+  });
+  if (!cont) return;
+
+  document.execCommand("insertText", false, cont);
+}
+
+function filterItem(dataItems, filterFunc) {
+  let data = [];
+  for (let index = 0; index < dataItems.length; index++) {
+    if (filterFunc(dataItems[index])) data.push(dataItems[index]);
+  }
+  return data;
+}
+
+export async function pasteHandle(event) {
+  _this = this;
+  // 禁止默认粘贴
+  event.preventDefault();
+  const clipboard = event.clipboardData;
+
+  const htmlItems = filterItem(
+    clipboard.items,
+    (item) => item.kind == "string" && item.type.match("^text/html")
+  );
+  if (htmlItems.length) {
+    console.log("... paste: html ");
+    for (let index = 0; index < htmlItems.length; index++) {
+      await pasteHtmlItem(htmlItems[index]);
+    }
+    // _this.$refs.editor.dispatchEvent(new Event("input"));
+    return;
+  }
+
+  const fileItems = filterItem(
+    clipboard.items,
+    (item) => item.kind == "file" && item.type.match("^image/")
+  );
+  if (fileItems.length) {
+    console.log("... paste: file ");
+    for (let index = 0; index < fileItems.length; index++) {
+      const file = fileItems[index].getAsFile();
+      await pasteImage(file);
+    }
+    // _this.$refs.editor.dispatchEvent(new Event("input"));
+    return;
+  }
+
+  for (var i = 0; i < clipboard.items.length; i++) {
+    const clipboardItem = clipboard.items[i];
+    if (
+      clipboardItem.kind == "string" &&
+      clipboardItem.type.match("^text/plain")
+    ) {
+      console.log("... paste: text ");
+      await pasteText(clipboardItem);
+    } else {
+      console.log("... paste: other ");
+    }
+  }
+  // _this.$refs.editor.dispatchEvent(new Event("input"));
+}

+ 151 - 0
src/components/vEditor/components/FormulaDialog.vue

@@ -0,0 +1,151 @@
+<template>
+  <el-dialog
+    class="formula-dialog"
+    :visible.sync="modalIsShow"
+    title="公式编辑"
+    top="10px"
+    width="850px"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    @opened="initData"
+    @closed="closed"
+  >
+    <div v-if="modalIsShow" class="math-body">
+      <iframe
+        ref="FormulaFrame"
+        :src="iframeSrc"
+        frameborder="0"
+        width="783"
+        height="386"
+      ></iframe>
+    </div>
+    <div slot="footer">
+      <el-button type="primary" :disabled="loading" @click="submit"
+        >确认</el-button
+      >
+      <el-button type="danger" plain @click="cancel">取消</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { saveFormulaImageToEditorHandle } from "./image";
+
+export default {
+  name: "FormulaDialog",
+  props: {
+    imageHandle: {
+      type: Function,
+      default() {
+        return {};
+      },
+    },
+  },
+  data() {
+    return {
+      modalIsShow: false,
+      iframeSrc: "",
+      loading: false,
+      // imgRate: 1,
+      imgRate: 0.625,
+    };
+  },
+  methods: {
+    getFrameUrl() {
+      let url = "";
+      const formulaPath = "kityformula-plugin/kityFormulaDialog.html";
+      const rootUrl = location.href.split("/#/")[0];
+      if (rootUrl.indexOf("index.html") !== -1) {
+        url = rootUrl.split("index.html")[0] + "/" + formulaPath;
+      } else {
+        url = `${rootUrl}/${formulaPath}`;
+      }
+
+      return url;
+    },
+    initData() {
+      this.iframeSrc = this.getFrameUrl();
+      const childFrameObj = this.$refs.FormulaFrame;
+      const editor = this.$parent.$parent.$refs.editor;
+      childFrameObj.onload = function () {
+        this.contentWindow.editor = editor;
+      };
+    },
+    closed() {
+      this.iframeSrc = "";
+    },
+    cancel() {
+      this.modalIsShow = false;
+    },
+    open() {
+      this.modalIsShow = true;
+    },
+    base64ToBlob(base64Str) {
+      var bytes = atob(base64Str.split(",")[1]);
+      let arr = new Uint8Array(bytes.length);
+      for (let i = 0; i < bytes.length; i++) {
+        arr[i] = bytes.charCodeAt(i);
+      }
+      return new Blob([arr], { type: "image/png" });
+    },
+    getFormulaResult() {
+      return new Promise((resolve, reject) => {
+        const childFrameObj = this.$refs.FormulaFrame;
+        const kfe = childFrameObj.contentWindow.kfe;
+
+        try {
+          kfe.execCommand("get.image.data", (data) => {
+            const latex = kfe.execCommand("get.source");
+            if (latex.indexOf("placeholder") > -1) {
+              this.modalIsShow = false;
+              this.loading = false;
+              resolve();
+              return;
+            }
+
+            const img = document.createElement("img");
+            img.src = data.img;
+            img.onload = () => {
+              resolve({
+                dataUrl: data.img,
+                latex,
+                param: {
+                  width: img.width * this.imgRate,
+                  height: img.height * this.imgRate,
+                },
+              });
+            };
+          });
+        } catch (error) {
+          reject(error);
+        }
+      });
+    },
+    async submit() {
+      if (this.loading) return;
+      this.loading = true;
+
+      const result = await this.getFormulaResult().catch((error) => {
+        console.error(error);
+        this.loading = false;
+      });
+
+      if (result) {
+        let res = true;
+        await saveFormulaImageToEditorHandle
+          .bind(this.$parent.$parent)(result.dataUrl, {
+            "data-latex": result.latex,
+            style: `width: ${result.param.width}px;height:${result.param.height}px;`,
+          })
+          .catch(() => {
+            res = false;
+          });
+        this.loading = false;
+        if (!res) return;
+      }
+      this.cancel();
+    },
+  },
+};
+</script>

+ 183 - 0
src/components/vEditor/components/VMenu.vue

@@ -0,0 +1,183 @@
+<template>
+  <div class="edit-menus">
+    <div class="edit-menu-item">
+      <img
+        class="intLink"
+        title="加粗"
+        src="/img/editor/menu_bold.png"
+        @mousedown="(event) => execCommand('bold', event)"
+      />
+    </div>
+    <div class="edit-menu-item">
+      <img
+        class="intLink"
+        title="倾斜"
+        src="/img/editor/menu_italic.png"
+        @mousedown="(event) => execCommand('italic', event)"
+      />
+    </div>
+    <div class="edit-menu-item">
+      <img
+        class="intLink"
+        title="下划线"
+        src="/img/editor/menu_underline.png"
+        @mousedown="(event) => execCommand('underline', event)"
+      />
+    </div>
+    <div class="edit-menu-item">
+      <img
+        class="intLink"
+        title="上标"
+        src="/img/editor/menu_superscript.png"
+        @mousedown="(event) => execCommand('superscript', event)"
+      />
+    </div>
+    <div class="edit-menu-item">
+      <img
+        class="intLink"
+        title="下标"
+        src="/img/editor/menu_subscript.png"
+        @mousedown="(event) => execCommand('subscript', event)"
+      />
+    </div>
+    <div v-if="$parent.enableAnswerPoint" class="edit-menu-item">
+      <img
+        class="intLink"
+        title="答题点"
+        src="/img/editor/answer_point_x.png"
+        @mousedown="addAnswerPoint"
+      />
+    </div>
+    <div class="edit-menu-item">
+      <img
+        class="intLink"
+        title="插入图片"
+        src="/img/editor/menu_image.png"
+        @mousedown="$refs.ImageInput.click()"
+      />
+      <input
+        ref="ImageInput"
+        type="file"
+        accept=".jpg,.jpeg,.png"
+        title="插入图片"
+        @change="addImage"
+      />
+    </div>
+    <!-- <div class="edit-menu-item">
+      <img
+        class="intLink"
+        title="插入音频"
+        src="/img/editor/menu_audio.png"
+        @mousedown="$refs.AudioInput.click()"
+      />
+      <input
+        ref="AudioInput"
+        type="file"
+        accept=".mp3"
+        title="插入音频"
+        @change="addAudio"
+      />
+    </div> -->
+    <div class="edit-menu-item">
+      <img
+        class="intLink"
+        title="插入公式"
+        src="/img/editor/menu_formula.png"
+        @mousedown="addFormula"
+      />
+    </div>
+
+    <!-- <span class="intLink" title="显示源码" @click="setDocMode('HTML')">
+      &lt;/&gt;
+    </span>
+    <span class="intLink padding-x" title="to JSON" @click="setDocMode('JSON')"
+      >{ }</span
+    > -->
+
+    <!-- formula dialog -->
+    <formula-dialog ref="FormulaDialog"></formula-dialog>
+  </div>
+</template>
+
+<script>
+import { setDocMode } from "./changeMode";
+// import { audioHandle } from "./audio";
+import { imageHandle } from "./image";
+import { answerPointHandle } from "./answerPoint";
+import FormulaDialog from "./FormulaDialog";
+
+export default {
+  name: "VMenu",
+  components: { FormulaDialog },
+  methods: {
+    setDocMode(type) {
+      setDocMode(type, this.$parent.$refs.editor);
+    },
+    execCommand(command, event) {
+      event.preventDefault();
+      document.execCommand(command);
+    },
+    /**
+     * @param {Event} event
+     */
+    // 暂时不考虑音频上传
+    // async addAudio(event) {
+    //   // console.log(event, event.target.files[0]);
+    //   audioHandle.bind(this.$parent)(event);
+    // },
+    /**
+     * @param {Event} event
+     */
+    async addImage(event) {
+      // console.log(event, event.target.files[0]);
+      imageHandle.bind(this.$parent)(event);
+    },
+    /**
+     * @param {Event} event
+     */
+    async addAnswerPoint(event) {
+      event.preventDefault();
+      answerPointHandle.bind(this.$parent)(event);
+    },
+    /**
+     * @param {Event} event
+     */
+    addFormula(event) {
+      event.preventDefault();
+      this.$refs.FormulaDialog.open();
+    },
+  },
+};
+</script>
+
+<style>
+.edit-menu-item {
+  position: relative;
+  display: inline-block;
+  vertical-align: middle;
+  height: 24px;
+  padding: 4px 0;
+  margin: 0 4px;
+  overflow: hidden;
+  cursor: pointer;
+}
+.edit-menu-item:hover img {
+  opacity: 0.6;
+}
+.edit-menu-item img {
+  display: block;
+  position: relative;
+  height: 100%;
+  z-index: 8;
+}
+.edit-menu-item input {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 30px;
+  opacity: 0;
+  z-index: 2;
+  overflow: hidden;
+  visibility: hidden;
+}
+</style>

+ 74 - 0
src/components/vEditor/components/answerPoint.js

@@ -0,0 +1,74 @@
+import { getAnswerPointImg } from "../utils";
+/**
+ * 和clipboard.js部分内容相同,注意同步修改
+ *
+ * @this {Object} VEditor 因为VEditor会带很多选项,所以绑定this传过来
+ */
+export async function answerPointHandle() {
+  this.$refs.editor.focus();
+
+  // let sel, range;
+  // if (window.getSelection) {
+  //   sel = window.getSelection();
+  // }
+
+  const node = document.createElement("img");
+  node.src = `/img/editor/answer_point_x.png`;
+  node.dataset.isAnswerPoint = true;
+  node.dataset.order = 0;
+
+  document.execCommand("insertHTML", false, node.outerHTML);
+
+  const answerPoints = this.$refs.editor.querySelectorAll(
+    "[data-is-answer-point]"
+  );
+  // console.log(answerPoints);
+  const answerPointsChanged = [];
+  [...answerPoints].forEach((answerPoint, index) => {
+    answerPointsChanged.push(+answerPoint.dataset.order); // 发现是0时,在answer里面增加一个元素
+    answerPoint.dataset.order = index + 1;
+    answerPoint.src = getAnswerPointImg(index + 1);
+  });
+  // console.log({ answerPointsChanged });
+  this.$emit("emitJSON");
+  this.$emit("answer-point-changed", answerPointsChanged);
+}
+
+/**
+ * 答题点拖动后更新,通过判断是否有 /img/editor/answer_point_x.png 来判断是否是拖动
+ * 因为drag会触发两次input,第一次事件是删除,要忽略第一次事件
+ *
+ * @param {InputEvent} event
+ * @this {Object} VEditor 因为VEditor会带很多选项,所以绑定this传过来
+ */
+export async function answerPointDragHandle(event) {
+  // console.log(event);
+  if (!["insertFromDrop", "deleteContentBackward"].includes(event.inputType)) {
+    // 只监听drop事件,因为会造成删除
+    return;
+  }
+  const answerPointX = this.$refs.editor.querySelectorAll(
+    "img[src='/img/editor/answer_point_x.png']"
+  );
+  // console.log(answerPointX);
+  const answerPoints = this.$refs.editor.querySelectorAll(
+    "[data-is-answer-point]"
+  );
+  // console.log(answerPoints);
+  // 没有答题点也会进入这里
+  if (answerPoints.length === 0 || answerPointX.length > 0) {
+    this.$emit("emitJSON");
+    this.$emit("answer-point-changed", []);
+    return;
+  }
+  // console.log("is drag", answerPoints);
+  const answerPointsChanged = [];
+  [...answerPoints].forEach((answerPoint, index) => {
+    answerPointsChanged.push(+answerPoint.dataset.order); // 发现是0时,在answer里面增加一个元素
+    answerPoint.dataset.order = index + 1;
+    answerPoint.src = getAnswerPointImg(index + 1);
+  });
+  // console.log({ answerPointsChanged });
+  this.$emit("emitJSON");
+  this.$emit("answer-point-changed", answerPointsChanged);
+}

+ 35 - 0
src/components/vEditor/components/audio.js

@@ -0,0 +1,35 @@
+// import { saveBlobToDisk } from "@/utils/fileUtils";
+import { audioToImageNode } from "../utils";
+
+/**
+ * 对粘贴事件进行处理:
+ * 1. text类型直接粘贴
+ * 2. 文件类型,判断图片
+ *
+ * @this {Object} VEditor 因为VEditor会带很多选项,所以绑定this传过来
+ * @param {Event} event
+ */
+export async function audioHandle(event) {
+  this.$refs.editor.focus();
+  /** @type {File} */
+  const file = event.target.files[0];
+  event.target.value = "";
+  if (file.size > this.maxAudioSize) {
+    this.$message(
+      `音频大小超过限制!最大不超过 ${
+        this.$parent.maxAudioSize / 1024 / 1024
+      } MB.`
+    );
+    return;
+  }
+
+  // console.log(this.$parent.folder, this.$parent.$refs.editor);
+  // TODO:上传音频文件
+  // const relativeFilePath = await saveBlobToDisk(this.folder, file, "mp3");
+  const relativeFilePath = "";
+  const imageNode = await audioToImageNode(relativeFilePath);
+  // console.log({ relativeFilePath, imageNode });
+  // console.log(imageNode.outerHTML);
+  document.execCommand("insertHTML", false, imageNode.outerHTML);
+  this.$emit("audio-added");
+}

+ 66 - 0
src/components/vEditor/components/changeMode.js

@@ -0,0 +1,66 @@
+const { toJSON } = require("../toJSON");
+
+let bToSource = false;
+let bToJSON = false;
+
+/**
+ *
+ * @param {string} type
+ * @param {HTMLDivElement} edt
+ */
+export function setDocMode(type, edt) {
+  if (type === "HTML") {
+    // if (bToSource === true) return;
+    bToJSON = false;
+    bToSource = !bToSource;
+  }
+  if (type === "JSON") {
+    if (bToSource === true) return;
+    bToSource = false;
+    bToJSON = !bToJSON;
+  }
+
+  let oContent;
+  if (bToSource) {
+    oContent = document.createTextNode(edt.innerHTML);
+    edt.innerHTML = "";
+    edt.contentEditable = false;
+    let oPre = document.createElement("pre");
+
+    oPre.className = "sourceView";
+    oPre.contentEditable = true;
+    oPre.style = "overflow: scroll; white-space: normal;";
+    oPre.appendChild(oContent);
+    edt.appendChild(oPre);
+    // 似乎chrome不支持将默认分段改为p
+    document.execCommand("defaultParagraphSeparator", false, "div");
+  } else if (bToJSON) {
+    const je = document.createElement("div");
+    je.style =
+      "position: absolute; top: 0; left: 0; background: beige; width: 100%; padding: 5px; z-index: 1000000";
+
+    oContent = document.createTextNode(toJSON(edt));
+
+    let oPre = document.createElement("pre");
+
+    // oPre.className = "sourceView";
+    edt.contentEditable = false;
+    oPre.style = "overflow: scroll;";
+    oPre.appendChild(oContent);
+    oPre.innerHTML = "双击关闭\n\n\n" + oPre.innerHTML;
+    je.appendChild(oPre);
+    document.body.appendChild(je);
+
+    je.addEventListener("dblclick", () => {
+      document.body.removeChild(je);
+      bToJSON = false;
+      edt.contentEditable = true;
+    });
+  } else {
+    oContent = document.createRange();
+    oContent.selectNodeContents(edt.firstChild);
+    edt.innerHTML = oContent.toString();
+    edt.contentEditable = true;
+  }
+  edt.focus();
+}

BIN
src/components/vEditor/components/horn.png


+ 55 - 0
src/components/vEditor/components/image.js

@@ -0,0 +1,55 @@
+import { insertTagToEditor } from "../utils";
+
+function toBase64(file) {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.readAsDataURL(file);
+    reader.onload = () => resolve(reader.result);
+    reader.onerror = (error) => reject(error);
+  });
+}
+
+/**
+ * 和clipboard.js部分内容相同,注意同步修改
+ *
+ * @this {Object} VEditor 因为VEditor会带很多选项,所以绑定this传过来
+ * @param {Event} event
+ */
+export async function imageHandle(event) {
+  this.$refs.editor.focus();
+  /** @type {File} */
+  const file = event.target.files[0];
+  event.target.value = "";
+  if (file.size > this.maxImageSize) {
+    this.$message(`单张图片超过限制,最大为 ${this.maxImageSize / 1024} KB.`);
+    return;
+  }
+
+  // 图片大于 10KB 或者 富文本总计超过 200KB的话,就以附件保存图片
+  // if (
+  //   file.size > this.imageExceedSizeAsAttachment ||
+  //   this.value.length > this.jsonExceedSizeAsAttachment
+  // ) {
+  //   // event.preventDefault();
+  //   const filePath = await saveBlobToServer(this.folder, file, "png");
+  //   insertTagToEditor(
+  //     this.$refs.editor,
+  //     "IMG",
+  //     filePath
+  //   );
+  // } else {
+  const srcBase64 = await toBase64(file);
+  insertTagToEditor(this.$refs.editor, "IMG", srcBase64);
+  // }
+}
+
+export async function saveFormulaImageToEditorHandle(imgDataUrl, attributes) {
+  // if (
+  //   file.size > this.imageExceedSizeAsAttachment ||
+  //   this.value.length > this.jsonExceedSizeAsAttachment
+  // ) {
+  //   // event.preventDefault();
+  // } else {
+  insertTagToEditor(this.$refs.editor, "IMG", imgDataUrl, attributes);
+  // }
+}

BIN
src/components/vEditor/components/upload_icon.png


+ 5 - 0
src/components/vEditor/constants.js

@@ -0,0 +1,5 @@
+export const IMAGE_EXCEED_SIZE_AS_ATTACHMENT = 10 * 1024;
+export const JSON_EXCEED_SIZE_AS_ATTACHMENT = 200 * 1024;
+export const MAX_AUDIO_SIZE = 5 * 1024 * 1024;
+export const MAX_IMAGE_SIZE = 200 * 1024;
+export const MAX_JSON_SIZE = 2 * 1024 * 1024;

+ 11 - 0
src/components/vEditor/index.d.ts

@@ -0,0 +1,11 @@
+interface RichTextJSON {
+  sections: RichTextSectionJSON[];
+}
+interface RichTextSectionJSON {
+  blocks: RichTextBlockJSON[];
+}
+interface RichTextBlockJSON {
+  type: 'text' | 'image' | 'audio' | 'cloze';
+  value: string;
+  param: object;
+}

+ 133 - 0
src/components/vEditor/renderJSON.js

@@ -0,0 +1,133 @@
+import { getAnswerPointImg } from "./utils";
+import { unix } from "moment";
+
+let _isPreview = true;
+// let _container;
+/**
+ * 将富文本 JSON 渲染到指定的元素中
+ *
+ * @param {RichTextJSON} body
+ * @param {HTMLDivElement} container
+ */
+export function renderRichText(body, container, isPreview = true) {
+  _isPreview = isPreview;
+  // _container = container;
+  let sections = body?.sections || [];
+  let nodes = [];
+  sections.forEach((section) => {
+    nodes.push(renderSection(section));
+  });
+  if (container != undefined) {
+    container.innerHTML = "";
+    nodes.forEach((node) => {
+      container.appendChild(node);
+    });
+  }
+}
+
+/**
+ * @param {RichTextSectionJSON} section
+ * @returns {HTMLDivElement} 返回根据 section 渲染好的 HTMLDivElement
+ */
+function renderSection(section) {
+  let blocks = section.blocks || [];
+  let inline = blocks.length > 1;
+  let node = document.createElement("div");
+  // node.style = "display: flex;";
+  blocks.forEach((block) => {
+    node.appendChild(renderBlock(block, inline));
+  });
+  return node;
+}
+
+/**
+ * @param {RichTextBlockJSON} block
+ * @param {Boolean} inline 图片是否以 inline 的样式展示
+ * @returns {HTMLElement} 返回根据 block 渲染好的 HTMLElement
+ */
+function renderBlock(block, inline) {
+  // let node = document.createElement('span')
+  // let classList = node.classList
+  let node;
+  if (block.type === "text") {
+    if (block.param) {
+      let nodeNames = ["u", "b", "i", "sup", "sub"];
+      let nodeParams = {
+        u: "underline",
+        b: "bold",
+        i: "italic",
+        sup: "sup",
+        sub: "sub",
+      };
+      const nodes = nodeNames
+        .filter((nodeName) => block.param[nodeParams[nodeName]])
+        .map((nodeName) => {
+          return document.createElement(nodeName);
+        });
+
+      nodes.push(document.createTextNode(block.value));
+      // 将不为空的元素依次append
+      node = nodes.reduceRight((p, c) => {
+        c.appendChild(p);
+        return c;
+      });
+    } else {
+      node = document.createTextNode(block.value);
+    }
+  } else if (block.type === "image") {
+    node = document.createElement("img");
+    if (inline) node.classList.add("inline");
+
+    node.src = block.value;
+    node.dataset.isImage = true;
+    // 公式latex表达式
+    if (block.latex) node.dataset.latex = block.latex;
+    // param
+    if (block.param) {
+      if (block.param.width) node.style.width = block.param.width + "px";
+      if (block.param.height) node.style.height = block.param.height + "px";
+    }
+  } else if (block.type === "cloze") {
+    node = document.createElement("img");
+    node.src = getAnswerPointImg(block.value);
+    node.dataset.isAnswerPoint = true;
+    node.dataset.order = block.value;
+  } else if (block.type === "audio") {
+    // classList.add("audio");
+    // let audio = node.appendChild(new Audio());
+    // audio.src = block.value;
+    // audio.controls = true;
+    // span 可以被追加文本内容,不好
+    // node = document.createElement("span");
+    // node.className = "audio";
+    // node.dataset["src"] = block.value;
+    // node.innerHTML = "&#9654;";
+    // // md5 src?
+    // node.id = "what";
+    // var sheet = window.document.styleSheets[0];
+    // sheet.insertRule(
+    //   `#what::after{content: "1:03"; font-size: 11px;}`,
+    //   sheet.cssRules.length
+    // );
+
+    if (_isPreview) {
+      node = document.createElement("audio");
+      node.className = "audio";
+      node.src = block.value;
+      node.controls = true;
+      node.controlsList = "nodownload";
+    } else {
+      node = document.createElement("img");
+      node.className = "audio";
+      node.src = "/img/editor/text_audio.png";
+      node.dataset.audioSrc = block.value;
+      node.dataset.isAudio = true;
+      node.dataset.duration = block.param.duration;
+      const mDuration = unix(0);
+      mDuration.second(block.param.duration);
+      node.title = mDuration.format("mm:ss");
+    }
+  }
+
+  return node;
+}

+ 166 - 0
src/components/vEditor/toJSON.js

@@ -0,0 +1,166 @@
+/**
+ * 将编辑器的 HTMLDivElement 转为我们需要的 JSON.stringify(RichTextJSON)
+ *
+ * @param {HTMLDivElement} editor
+ *
+ * @returns {String} JSON.stringify(RichTextJSON)
+ */
+export function toJSON(editor) {
+  let newSections = [];
+  const nodesSectionsCollector = toNodeSections(editor);
+  nodesSectionsCollector.forEach((section) => {
+    let newSection = { blocks: [] };
+    section.forEach((elem) => {
+      const newBlock = toJSONBlock(elem);
+      if (newBlock) newSection.blocks.push(newBlock);
+    });
+
+    if (!newSection.blocks.length) {
+      // 空行特殊处理
+      newSection.blocks = [{ type: "text", value: "", param: null }];
+    }
+    newSections.push(newSection);
+  });
+  // console.log(newSections);
+
+  /** @type {RichTextJSON} */
+  const result = { sections: newSections };
+  return JSON.stringify(result, null);
+}
+
+function toNodeSections(node) {
+  let sections = [];
+  let curSection = [];
+
+  const checkIsDiv = (elem) => {
+    return elem.nodeType == Node.ELEMENT_NODE && elem.nodeName === "DIV";
+  };
+
+  const parseNode = (node) => {
+    node.childNodes.forEach((elem) => {
+      if (checkIsDiv(elem) && curSection.length) {
+        sections.push(curSection);
+        curSection = [];
+      }
+
+      if (elem.childNodes && elem.childNodes.length) {
+        parseNode(elem);
+      } else {
+        curSection.push(elem);
+      }
+    });
+  };
+
+  parseNode(node);
+
+  if (curSection.length) {
+    sections.push(curSection);
+  }
+
+  // console.log(sections);
+  return sections;
+}
+
+/**
+ * 目前限定结构只有三层 u b i
+ *
+ * @param {Node} e
+ * @param {String} tag
+ */
+function checkAncestorElementTag(e) {
+  let parentElement = e.parentElement;
+  let elementTags = [];
+  let validTags = ["I", "B", "U", "SUB", "SUP"];
+  while (
+    parentElement &&
+    parentElement.className &&
+    parentElement.className.indexOf("v-editor-body") === -1
+  ) {
+    if (validTags.includes(parentElement.nodeName)) {
+      elementTags.push(parentElement.nodeName);
+    } else {
+      parentElement = parentElement.parentElement;
+    }
+  }
+  return elementTags;
+}
+
+/**
+ * 将 HTML Node 转为 block (JSON)
+ * @param {Node} e
+ * @returns {RichTextBlockJSON}
+ */
+function toJSONBlock(e) {
+  /** @type {RichTextBlockJSON} */
+  let block = {};
+  if (e.nodeType === Node.TEXT_NODE) {
+    block.type = "text";
+    block.value = e.textContent;
+    const elementTags = checkAncestorElementTag(e);
+    let param = {
+      italic: elementTags.includes("I"),
+      bold: elementTags.includes("B"),
+      underline: elementTags.includes("U"),
+      sup: elementTags.includes("SUP"),
+      sub: elementTags.includes("SUB"),
+    };
+    const allFalse = !Object.values(param).some((v) => v);
+    block.param = allFalse ? null : param;
+  } else if (e.nodeType == Node.ELEMENT_NODE && e.nodeName === "U") {
+    block.type = "text";
+    block.value = e.textContent;
+    block.param = { underline: true };
+  } else if (e.nodeType == Node.ELEMENT_NODE && e.nodeName === "B") {
+    block.type = "text";
+    block.value = e.textContent;
+    block.param = { bold: true };
+  } else if (e.nodeType == Node.ELEMENT_NODE && e.nodeName === "I") {
+    block.type = "text";
+    block.value = e.textContent;
+    block.param = { italic: true };
+  } else if (
+    e.nodeType == Node.ELEMENT_NODE &&
+    e.nodeName === "IMG" &&
+    e.dataset.isImage
+  ) {
+    block.type = "image";
+    // 公式latex表达式
+    if (e.dataset.latex) {
+      block.latex = e.dataset.latex;
+    }
+    // change base64 image size, draw canvas
+    block.value = e.src;
+    block.param = {
+      width: e.clientWidth,
+      height: e.clientHeight,
+    };
+  } else if (
+    e.nodeType == Node.ELEMENT_NODE &&
+    e.nodeName === "IMG" &&
+    e.dataset.isAudio
+  ) {
+    block.type = "audio";
+    // 音频使用云端地址
+    block.value = e.dataset.audioSrc;
+    block.param = { duration: e.dataset.duration };
+  } else if (
+    e.nodeType == Node.ELEMENT_NODE &&
+    e.nodeName === "IMG" &&
+    e.dataset.isAnswerPoint
+  ) {
+    block.type = "cloze";
+    block.value = e.dataset.order * 1;
+    block.param = null;
+  } else if (e.nodeType == Node.ELEMENT_NODE && e.nodeName === "BR") {
+    block.type = "text";
+    block.value = "";
+    block.param = null;
+  } else {
+    console.log("toJSONBlock: 非法", e);
+  }
+
+  // if (block.type === "text" && block.value === "") {
+  //   block = null; // 返回null的block,从json中剔除
+  // }
+  return block;
+}

+ 91 - 0
src/components/vEditor/utils.js

@@ -0,0 +1,91 @@
+/**
+ *
+ * @param {HTMLDivElement} editor 要操作的编辑器dom,还未想好怎么用
+ * @param {String} tagName IMG | AUDIO
+ * @param {String} value 相对于本地电脑资源的路径
+ */
+export function insertTagToEditor(editor, tagName, value, attributes = {}) {
+  let sel, range;
+  if (window.getSelection) {
+    sel = window.getSelection();
+    if (sel.getRangeAt && sel.rangeCount) {
+      range = sel.getRangeAt(0);
+      range.deleteContents();
+
+      // TODO:
+      // if (tagName === "AUDIO") {
+      //   const el = document.createElement("audio");
+      //   el.src = value;
+      //   el.className = "audio";
+      //   // el.style = "height: 18px; width: 180px;";
+      //   el.controls = true;
+      //   el.controlsList = "nodownload";
+      //   // el.draggable = true;
+      //   range.insertNode(el);
+      // }
+      if (tagName === "IMG") {
+        const el = document.createElement("img");
+        // console.log(value);
+        el.src = value;
+        el.dataset.isImage = true;
+        if (Object.keys(attributes).length) {
+          Object.keys(attributes).forEach((k) => {
+            el.setAttribute(k, attributes[k]);
+          });
+        }
+        range.insertNode(el);
+        range.collapse();
+      }
+      editor && editor.dispatchEvent(new Event("input"));
+    }
+  }
+}
+
+/**
+ * 将音频路径转换为图片占位符
+ *
+ * @param {aduioSrc} value 相对于本地电脑资源的路径
+ * @returns {HTMLImageElement} 图片节点
+ */
+export async function audioToImageNode(audioSrc) {
+  const duration = 1;
+
+  const el = document.createElement("img");
+  el.src = "/img/editor/text_audio.png";
+  el.dataset.isAudio = true;
+  el.dataset.audioSrc = audioSrc;
+  el.dataset.duration = duration;
+  const mDuration = require("moment").unix(0);
+  mDuration.second(duration);
+  el.title = mDuration.format("mm:ss");
+
+  el.className = "audio";
+
+  return el;
+}
+
+/**
+ * 获取答题点序号图片base64
+ * @param {number} serialNo 答题点序号
+ * @returns dataUrl
+ */
+export function getAnswerPointImg(serialNo) {
+  const canvas = document.createElement("canvas");
+  const ctx = canvas.getContext("2d");
+  canvas.width = 64;
+  canvas.height = 32;
+  ctx.font = "bolder 30px serif";
+  ctx.textAlign = "center";
+  ctx.textBaseline = "middle";
+  ctx.fillText(`(${serialNo})`, 32, 16);
+  return canvas.toDataURL();
+}
+
+export function base64ToBlob(base64Str) {
+  var bytes = atob(base64Str.split(",")[1]);
+  let arr = new Uint8Array(bytes.length);
+  for (let i = 0; i < bytes.length; i++) {
+    arr[i] = bytes.charCodeAt(i);
+  }
+  return new Blob([arr], { type: "image/png" });
+}

+ 3 - 5
src/modules/questions/views/EditOtherQuestion.vue

@@ -190,7 +190,7 @@
       <el-form class="form-tight" label-width="100px">
         <!-- end -->
         <el-form-item label="题干" prop="quesBody">
-          <ckeditor v-model="quesModel.quesBody"></ckeditor>
+          <v-editor v-model="quesModel.quesBody"></v-editor>
         </el-form-item>
 
         <el-form-item
@@ -202,7 +202,7 @@
               {{ index | optionOrderWordFilter }}
             </div>
             <div class="option-body">
-              <ckeditor v-model="option.quesBody"></ckeditor>
+              <v-editor v-model="option.quesBody"></v-editor>
             </div>
           </div>
         </el-form-item>
@@ -218,7 +218,7 @@
           label="答案"
           prop="quesAnswer"
         >
-          <ckeditor v-model="quesModel.quesAnswer"></ckeditor>
+          <v-editor v-model="quesModel.quesAnswer"></v-editor>
         </el-form-item>
         <el-form-item
           v-if="quesModel.questionType == 'BOOL_ANSWER_QUESTION'"
@@ -237,11 +237,9 @@
 <script>
 import { QUESTION_API } from "@/constants/constants";
 import { isEmptyStr, QUESTION_TYPES } from "../constants/constants";
-import ckeditor from "../component/ckeditor.vue";
 
 export default {
   name: "EditOtherApp",
-  components: { ckeditor },
   data() {
     return {
       fullscreenLoading: false,

+ 6 - 9
src/modules/questions/views/EditPaper.vue

@@ -4,7 +4,6 @@
     class="edit-paper"
     element-loading-text="拼命加载中。。。"
   >
-    <!-- <ckeditor v-model="examRemark"></ckeditor> -->
     <div class="edit-header">
       <div class="edit-header-top box-justify">
         <div class="header-info">
@@ -559,10 +558,10 @@
           ></el-input>
         </el-form-item>
         <el-form-item label="大题描述">
-          <ckeditor
+          <v-editor
             v-model="editpaperDetail.description"
             class="area-ckeditor"
-          ></ckeditor>
+          ></v-editor>
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -809,7 +808,7 @@
         </el-row>
         <!-- end by weiwenhai -->
         <el-form-item label="题目">
-          <ckeditor v-model="quesModel.quesBody"></ckeditor>
+          <v-editor v-model="quesModel.quesBody"></v-editor>
         </el-form-item>
         <el-form-item
           v-for="(quesOption, optIndex) in quesModel.quesOptions"
@@ -877,7 +876,7 @@
               "
               class="option-body"
             >
-              <ckeditor v-model="quesOption.optionBody"></ckeditor>
+              <v-editor v-model="quesOption.optionBody"></v-editor>
             </div>
             <div
               v-if="
@@ -927,7 +926,7 @@
           "
         >
           <el-form-item label="答案">
-            <ckeditor v-model="quesModel.quesAnswer"></ckeditor>
+            <v-editor v-model="quesModel.quesAnswer"></v-editor>
           </el-form-item>
         </div>
         <!-- 单选或多选 -->
@@ -978,7 +977,7 @@
     >
       <el-form label-position="top">
         <el-form-item label="考试说明:">
-          <ckeditor v-model="examRemark" class="area-ckeditor"></ckeditor>
+          <v-editor v-model="examRemark" emit-type="html"></v-editor>
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -1143,7 +1142,6 @@
 import { QUESTION_API } from "@/constants/constants";
 import { isEmptyStr, QUESTION_TYPES } from "../constants/constants";
 import { mapState } from "vuex";
-import ckeditor from "../component/ckeditor.vue";
 import PaperBasicComposition from "./PaperBasicComposition.vue";
 import PaperQuestionType from "./PaperQuestionType.vue";
 import PaperBlue from "./PaperBlue.vue";
@@ -1152,7 +1150,6 @@ import AuditInfo from "./AuditInfo.vue";
 export default {
   name: "EditPaperApp",
   components: {
-    ckeditor,
     PaperBasicComposition,
     PaperQuestionType,
     PaperBlue,

+ 5 - 10
src/modules/questions/views/EditPaperPendingTrial.vue

@@ -603,10 +603,7 @@
           ></el-input>
         </el-form-item>
         <el-form-item label="大题描述">
-          <ckeditor
-            v-model="editpaperDetail.description"
-            class="area-ckeditor"
-          ></ckeditor>
+          <v-editor v-model="editpaperDetail.description"></v-editor>
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -852,7 +849,7 @@
         </el-row>
         <!-- end by weiwenhai -->
         <el-form-item label="题目">
-          <ckeditor v-model="quesModel.quesBody"></ckeditor>
+          <v-editor v-model="quesModel.quesBody"></v-editor>
         </el-form-item>
         <el-form-item
           v-for="(quesOption, optIndex) in quesModel.quesOptions"
@@ -920,7 +917,7 @@
               "
               class="option-body"
             >
-              <ckeditor v-model="quesOption.optionBody"></ckeditor>
+              <v-editor v-model="quesOption.optionBody"></v-editor>
             </div>
             <div
               v-if="
@@ -970,7 +967,7 @@
           "
         >
           <el-form-item label="答案">
-            <ckeditor v-model="quesModel.quesAnswer"></ckeditor>
+            <v-editor v-model="quesModel.quesAnswer"></v-editor>
           </el-form-item>
         </div>
         <!-- 单选或多选 -->
@@ -1017,7 +1014,7 @@
     >
       <el-form label-position="top">
         <el-form-item label="考试说明:">
-          <ckeditor v-model="examRemark" class="area-ckeditor"></ckeditor>
+          <v-editor v-model="examRemark" emit-type="html"></v-editor>
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -1198,7 +1195,6 @@
 import { QUESTION_API } from "@/constants/constants";
 import { isEmptyStr, QUESTION_TYPES } from "../constants/constants";
 import { mapState } from "vuex";
-import ckeditor from "../component/ckeditor.vue";
 import PaperBasicComposition from "./PaperBasicComposition.vue";
 import PaperQuestionType from "./PaperQuestionType.vue";
 import PaperBlue from "./PaperBlue.vue";
@@ -1208,7 +1204,6 @@ import AuditPaper from "./AuditPaper.vue";
 export default {
   name: "EditPaperApp",
   components: {
-    ckeditor,
     PaperBasicComposition,
     PaperQuestionType,
     PaperBlue,

+ 4 - 4
src/modules/questions/views/EditSelectQuestion.vue

@@ -194,7 +194,7 @@
     <div class="part-box">
       <el-form class="form-tight" label-width="100px">
         <el-form-item label="题干" prop="quesBody">
-          <ckeditor v-model="quesModel.quesBody"></ckeditor>
+          <v-editor v-model="quesModel.quesBody"></v-editor>
         </el-form-item>
         <el-form-item
           v-for="(option, index) in quesModel.quesOptions"
@@ -214,7 +214,7 @@
               ></el-checkbox>
             </div>
             <div class="option-body">
-              <ckeditor v-model="option.optionBody"></ckeditor>
+              <v-editor v-model="option.optionBody"></v-editor>
             </div>
             <div class="option-delete">
               <el-button
@@ -247,11 +247,11 @@
 <script>
 import { QUESTION_API } from "@/constants/constants";
 import { isEmptyStr, QUESTION_TYPES } from "../constants/constants";
-import ckeditor from "../component/ckeditor.vue";
+// import ckeditor from "../component/ckeditor.vue";
 
 export default {
   name: "EditSelectApp",
-  components: { ckeditor },
+  // components: { ckeditor },
   data() {
     return {
       questionTypes: QUESTION_TYPES,

+ 3 - 7
src/modules/questions/views/InsertBluePaperStructure.vue

@@ -80,12 +80,12 @@
         </el-form-item>
         <el-form-item label-width="0px"> </el-form-item>
       </el-form>
-      <ckeditor
+      <v-editor
         v-model="blueStruct.examRemark"
-        class="area-ckeditor"
         :height="hValue"
+        emit-type="html"
         placeholder="请输入考试说明"
-      ></ckeditor>
+      ></v-editor>
     </div>
     <div class="part-box">
       <!-- 页面列表 -->
@@ -206,13 +206,9 @@
 <script>
 import { QUESTION_TYPES } from "../constants/constants";
 import { QUESTION_API } from "@/constants/constants";
-import ckeditor from "../component/ckeditor.vue";
 
 export default {
   name: "InsertBluePaperStructure",
-  components: {
-    ckeditor,
-  },
   data() {
     return {
       hValue: "100px",

+ 3 - 7
src/modules/questions/views/InsertPaperStructure.vue

@@ -68,12 +68,12 @@
         </el-form-item>
         <el-form-item label-width="0px"> </el-form-item>
       </el-form>
-      <ckeditor
+      <v-editor
         v-model="paperStruct.examRemark"
-        class="area-ckeditor"
         :height="hValue"
+        emit-type="html"
         placeholder="请输入考试说明"
-      ></ckeditor>
+      ></v-editor>
     </div>
     <div class="part-box">
       <!-- 页面列表 -->
@@ -187,13 +187,9 @@
 
 <script>
 import { QUESTION_API } from "@/constants/constants";
-import ckeditor from "../component/ckeditor.vue";
 
 export default {
   name: "InsertPaperStructure",
-  components: {
-    ckeditor,
-  },
   data() {
     return {
       hValue: "100px",

+ 6 - 1
src/plugins/globalVuePlugins.js

@@ -1,4 +1,7 @@
 import { objAssign } from "@/plugins/utils";
+// component
+import VEditor from "../components/vEditor/VEditor";
+import RichText from "../components/RichText";
 // mixins
 import commonMixins from "../mixins/common";
 
@@ -6,7 +9,9 @@ export default {
   install: function (Vue) {
     // 实例方法
     Vue.prototype.$objAssign = objAssign;
-
+    // component
+    Vue.component("VEditor", VEditor);
+    Vue.component("RichText", RichText);
     //全局 mixins
     Vue.mixin(commonMixins);
   },