VEditor.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. <template>
  2. <div class="v-editor">
  3. <VMenu class="v-editor-head" />
  4. <div :class="['v-editor-container', { 'is-focus': isFocus }]">
  5. <div ref="editorMain" class="v-editor-main">
  6. <div
  7. :id="'ved' + _uid"
  8. ref="editor"
  9. :class="['v-editor-body', `v-editor-body-${_uid}`]"
  10. :data-placeholder="placeholder"
  11. contenteditable
  12. :style="styles"
  13. @input="emitJSON"
  14. @focus="isFocus = true"
  15. @blur="editorBlur"
  16. ></div>
  17. </div>
  18. </div>
  19. </div>
  20. </template>
  21. <script>
  22. import VMenu from "./components/VMenu.vue";
  23. import { renderRichText } from "./renderJSON";
  24. import { toJSON } from "./toJSON";
  25. import {
  26. IMAGE_EXCEED_SIZE_AS_ATTACHMENT,
  27. JSON_EXCEED_SIZE_AS_ATTACHMENT,
  28. MAX_AUDIO_SIZE,
  29. MAX_IMAGE_SIZE,
  30. MAX_JSON_SIZE,
  31. } from "./constants";
  32. import { pasteHandle } from "./clipboard";
  33. import { answerPointRebuild } from "./components/answerPoint";
  34. import timeMixin from "@/mixins/timeMixin";
  35. export default {
  36. name: "VEditor",
  37. components: {
  38. VMenu,
  39. },
  40. mixins: [timeMixin],
  41. props: {
  42. placeholder: { type: String, default: "请输入..." },
  43. value: {
  44. type: [String, Object],
  45. // 要么为null,要么一定要遵循结构 body.sections[]
  46. // const EMPTY_RICH_TEXT
  47. default: () => {
  48. return { sections: [] };
  49. },
  50. },
  51. styles: { type: String, default: "" },
  52. folder: { type: String, default: "" },
  53. enableAnswerPoint: { type: Boolean, default: false },
  54. enableFormula: { type: Boolean, default: true },
  55. enableAudio: { type: Boolean, default: true },
  56. maxAudioSize: { type: Number, default: MAX_AUDIO_SIZE, required: false },
  57. maxImageSize: { type: Number, default: MAX_IMAGE_SIZE, required: false },
  58. imageExceedSizeAsAttachment: {
  59. type: Number,
  60. default: IMAGE_EXCEED_SIZE_AS_ATTACHMENT,
  61. required: false,
  62. },
  63. jsonExceedSizeAsAttachment: {
  64. type: Number,
  65. default: JSON_EXCEED_SIZE_AS_ATTACHMENT,
  66. required: false,
  67. },
  68. maxJsonSize: { type: Number, default: MAX_JSON_SIZE, required: false },
  69. emitType: {
  70. type: String,
  71. default: "json",
  72. },
  73. },
  74. data() {
  75. return {
  76. isFocus: false,
  77. };
  78. },
  79. watch: {
  80. value(val, oldVal) {
  81. if (val !== oldVal) {
  82. if (this.$refs.editor !== document.activeElement) {
  83. this.initData();
  84. }
  85. }
  86. },
  87. },
  88. mounted() {
  89. this.initData();
  90. this.$refs.editor.addEventListener("paste", pasteHandle.bind(this));
  91. this.$refs.editor.addEventListener("click", this.clickEventHandle);
  92. },
  93. beforeDestroy() {
  94. this.clearSetTs();
  95. },
  96. methods: {
  97. initData() {
  98. if (this.emitType === "html") {
  99. this.$refs.editor.innerHTML = this.value;
  100. } else {
  101. const content = this.value || { sections: [] };
  102. renderRichText(content, this.$refs.editor, false);
  103. }
  104. },
  105. emitJSON($event) {
  106. if (!this.$refs.editor.contentEditable) {
  107. // 不是出于contentEditable则不更新
  108. return;
  109. }
  110. if (this.enableAnswerPoint) answerPointRebuild.bind(this)($event);
  111. if (this.emitType === "html") {
  112. const content = this.$refs.editor.innerHTML;
  113. this.$emit("input", content);
  114. this.$emit("change", content);
  115. return;
  116. }
  117. this.clearSetTs();
  118. // 延迟触发input任务,避免频繁触发,同时也等待图片渲染,方便获取图片显示尺寸
  119. this.addSetTime(() => {
  120. // console.log("input:" + Math.random());
  121. this.inputDelaying = false;
  122. const json = toJSON(this.$refs.editor);
  123. this.$emit("input", json);
  124. this.$emit("change", json);
  125. // this.$emit("on-result", json);
  126. }, 200);
  127. },
  128. emitJsonStrict() {
  129. if (this.emitType === "html") {
  130. const content = this.$refs.editor.innerHTML;
  131. this.$emit("input", content);
  132. this.$emit("change", content);
  133. return;
  134. }
  135. const json = toJSON(this.$refs.editor);
  136. this.$emit("input", json);
  137. this.$emit("change", json);
  138. },
  139. // image resize
  140. clickEventHandle(e) {
  141. const el = e.target;
  142. if (el.tagName && el.tagName === "IMG") {
  143. e.preventDefault();
  144. e.stopPropagation();
  145. if (el.dataset.latex) {
  146. const selection = window.getSelection();
  147. selection.removeAllRanges();
  148. const range = document.createRange();
  149. range.selectNode(el);
  150. selection.addRange(range);
  151. }
  152. this.clearActiveImg();
  153. el.className = `${el.className} is-active`;
  154. this.activeResize(el);
  155. return;
  156. }
  157. },
  158. clearActiveImg() {
  159. this.$refs.editor.querySelectorAll("img").forEach((imgDom) => {
  160. if (!imgDom.className.indexOf("is-active")) return;
  161. let names = imgDom.className
  162. .split(" ")
  163. .filter((item) => item && item !== "is-active");
  164. imgDom.className = names.join(" ");
  165. });
  166. },
  167. clearResizeDom() {
  168. let doms = this.$refs.editorMain.querySelectorAll(".resize-elem");
  169. if (!doms.length) return;
  170. doms.forEach((dom) => {
  171. dom.parentNode.removeChild(dom);
  172. });
  173. },
  174. activeResize(imgDom) {
  175. const _this = this;
  176. let _x = 0;
  177. let { clientWidth, naturalWidth, naturalHeight } = imgDom;
  178. let resizeDom = null;
  179. removeResizeDom();
  180. createResizeDom();
  181. function createResizeDom() {
  182. resizeDom = document.createElement("div");
  183. resizeDom.className = "resize-elem";
  184. resizeDom.style.position = "absolute";
  185. resizeDom.style.width = "16px";
  186. resizeDom.style.height = "16px";
  187. resizeDom.style.backgroundColor = "#fff";
  188. resizeDom.style.backgroundImage =
  189. "url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAX1JREFUWEftlTFLw1AQx+/S2dEvUbe8g05CHbroKm7iIHQW1LlxraAfQHDoKDjqJNhBhJJ32SzOrvoRSv/yJIEaoq8kxi7JFAi534//e3fHtOKHV8ynRqBJoEnAm4Ax5jFt1Xdmnqbvb8z8Gsfxc9U2XlagWwAaq+rWfwjcEdF2DjQNgmAvjuOX2gVEZAjgdBHEzA/W2l5VuPvfewQiEgEY5GHMfGut3a0q4RUIw/CImS+JaArghpmjDMrM19bawyoSXgER6c3n86jVavXdmYdhGDHzYiIjVT0oK+EVKCqclwAQJUlyVkailIADFdyNb23Z6XTak8kkmxs/upUWSCX2AYwWqn9JiMjApUJE66r68VsylQRc4YI7MSaibHBtqupTrQKuuDHmhIjO8yAA/SRJrmoXSCXczsiP7AtVPa5doOAYMua9qu7UKiAiXQDZxsyzvAur8iXMiK7tZrPZBoB2EAQGwJr75tuYfyZQZggttYzKFl72vyaBJoEmgU/j14shnky4jQAAAABJRU5ErkJggg==')";
  190. resizeDom.style.backgroundSize = "100% 100%";
  191. resizeDom.style.borderTop = "1px solid #1886fe";
  192. resizeDom.style.borderLeft = "1px solid #1886fe";
  193. resizeDom.style.cursor = "nwse-resize";
  194. resizeDom.style.zIndex = 9999;
  195. _this.$refs.editorMain.appendChild(resizeDom);
  196. resizeResizeDom();
  197. }
  198. function removeResizeDom() {
  199. _this.clearResizeDom();
  200. resizeDom = null;
  201. }
  202. function resizeResizeDom() {
  203. const imgPos = imgDom.getBoundingClientRect();
  204. const mainPos = _this.$refs.editorMain.getBoundingClientRect();
  205. let relativeLeft = imgPos.x - mainPos.x;
  206. let relativeTop = imgPos.y - mainPos.y;
  207. resizeDom.style.left = imgPos.width + relativeLeft - 16 + "px";
  208. resizeDom.style.top = imgPos.height + relativeTop - 16 + "px";
  209. }
  210. function getValidSize(originSize, size) {
  211. if (size <= 10) return Math.min(originSize, 10);
  212. if (size >= 2 * originSize) return 2 * originSize;
  213. return size;
  214. }
  215. // 只允许鼠标左键触发
  216. function moveHandle(e) {
  217. if (e.button !== 0) return;
  218. e.preventDefault();
  219. e.stopPropagation();
  220. let width = getValidSize(naturalWidth, e.pageX - _x + clientWidth);
  221. let height = (width * naturalHeight) / naturalWidth;
  222. imgDom.style.width = width + "px";
  223. imgDom.style.height = height + "px";
  224. resizeResizeDom();
  225. }
  226. function upHandle(e) {
  227. _this.emitJsonStrict();
  228. if (e.button !== 0) return;
  229. e.preventDefault();
  230. e.stopPropagation();
  231. document.removeEventListener("mousemove", moveHandle);
  232. document.removeEventListener("mouseup", upHandle);
  233. }
  234. resizeDom.addEventListener("mousedown", function (e) {
  235. if (e.button !== 0) return;
  236. _x = e.pageX;
  237. e.preventDefault();
  238. e.stopPropagation();
  239. document.addEventListener("mousemove", moveHandle);
  240. document.addEventListener("mouseup", upHandle);
  241. });
  242. },
  243. editorBlur() {
  244. this.isFocus = false;
  245. this.clearActiveImg();
  246. this.clearResizeDom();
  247. },
  248. },
  249. };
  250. </script>
  251. <style>
  252. .v-editor {
  253. position: relative;
  254. line-height: 20px;
  255. }
  256. .v-editor-head {
  257. position: absolute;
  258. left: 2px;
  259. right: 2px;
  260. background-color: #fff;
  261. top: 2px;
  262. z-index: 9;
  263. border-bottom: 1px solid #f0f0f0;
  264. }
  265. .v-editor-container {
  266. border: 1px solid #e4e7ed;
  267. border-radius: 5px;
  268. min-height: 100px;
  269. max-height: 300px;
  270. padding: 26px 0 0 0;
  271. overflow: auto;
  272. white-space: pre-wrap;
  273. }
  274. .v-editor-container.is-focus {
  275. border-color: #1886fe;
  276. }
  277. .v-editor-main {
  278. position: relative;
  279. min-height: 100%;
  280. }
  281. .v-editor-body {
  282. outline: none;
  283. min-height: 100%;
  284. padding: 8px;
  285. }
  286. .v-editor-body[contenteditable="true"]:empty:not(:focus)::before {
  287. content: attr(data-placeholder);
  288. color: grey;
  289. }
  290. .v-editor-body div {
  291. min-height: 18px;
  292. line-height: 20px;
  293. }
  294. .v-editor-body img {
  295. max-width: 100%;
  296. }
  297. .v-editor-body img[data-is-answer-point] {
  298. max-height: 16px;
  299. display: inline-block;
  300. vertical-align: text-top;
  301. border-bottom: 1px solid #000;
  302. }
  303. .v-editor-body img.audio {
  304. height: 16px;
  305. padding: 0 2px;
  306. display: inline-block;
  307. margin-bottom: 4px;
  308. }
  309. .v-editor-body img[data-is-image] {
  310. /* max-height: 42px; */
  311. display: inline-block;
  312. }
  313. .v-editor-body img[data-is-image].is-active {
  314. box-shadow: 0 0 1px 1px #1886fe;
  315. }
  316. .v-editor-body audio {
  317. height: 16px;
  318. width: 180px;
  319. display: inline-block;
  320. margin-bottom: -4px;
  321. }
  322. .v-editor-body ::-webkit-media-controls-mute-button {
  323. display: none !important;
  324. }
  325. .v-editor-body ::-webkit-media-controls-volume-slider {
  326. display: none !important;
  327. }
  328. .v-editor-body b {
  329. font-weight: bold;
  330. }
  331. .v-editor-body i {
  332. font-style: italic;
  333. }
  334. .v-editor-body u {
  335. text-decoration: underline;
  336. }
  337. </style>