VEditor.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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("wheel", this.wheelEventHandle);
  92. this.$refs.editor.addEventListener("click", this.clickEventHandle);
  93. },
  94. beforeDestroy() {
  95. this.clearSetTs();
  96. },
  97. methods: {
  98. initData() {
  99. if (this.emitType === "html") {
  100. this.$refs.editor.innerHTML = this.value;
  101. } else {
  102. const content = this.value || { sections: [] };
  103. renderRichText(content, this.$refs.editor, false);
  104. }
  105. },
  106. emitJSON($event) {
  107. if (!this.$refs.editor.contentEditable) {
  108. // 不是出于contentEditable则不更新
  109. return;
  110. }
  111. if (this.enableAnswerPoint) answerPointRebuild.bind(this)($event);
  112. if (this.emitType === "html") {
  113. const content = this.$refs.editor.innerHTML;
  114. this.$emit("input", content);
  115. this.$emit("change", content);
  116. return;
  117. }
  118. this.clearSetTs();
  119. // 延迟触发input任务,避免频繁触发,同时也等待图片渲染,方便获取图片显示尺寸
  120. this.addSetTime(() => {
  121. // console.log("input:" + Math.random());
  122. this.inputDelaying = false;
  123. const json = toJSON(this.$refs.editor);
  124. this.$emit("input", json);
  125. this.$emit("change", json);
  126. // this.$emit("on-result", json);
  127. }, 200);
  128. },
  129. emitJsonStrict() {
  130. if (this.emitType === "html") {
  131. const content = this.$refs.editor.innerHTML;
  132. this.$emit("input", content);
  133. this.$emit("change", content);
  134. return;
  135. }
  136. const json = toJSON(this.$refs.editor);
  137. this.$emit("input", json);
  138. this.$emit("change", json);
  139. },
  140. wheelEventHandle(e) {
  141. // console.log(e);
  142. // console.dir(e.target);
  143. const el = e.target;
  144. if (el.tagName && el.tagName === "IMG") {
  145. e.preventDefault();
  146. e.stopPropagation();
  147. const shift = e.deltaY > 0;
  148. const newWidth =
  149. +getComputedStyle(el).width.replace("px", "") + (shift ? 1 : -1);
  150. // console.log(newWidth, el.naturalWidth);
  151. if (newWidth >= 16 && newWidth <= el.naturalWidth) {
  152. el.style.width = newWidth + "px";
  153. el.style.height =
  154. (newWidth / el.naturalWidth) * el.naturalHeight + "px";
  155. // el.setAttribute("width", newWidth);
  156. this.emitJSON();
  157. }
  158. }
  159. },
  160. clickEventHandle(e) {
  161. const el = e.target;
  162. if (el.tagName && el.tagName === "IMG") {
  163. e.preventDefault();
  164. e.stopPropagation();
  165. if (el.dataset.latex) {
  166. const selection = window.getSelection();
  167. selection.removeAllRanges();
  168. const range = document.createRange();
  169. range.selectNode(el);
  170. selection.addRange(range);
  171. }
  172. this.clearActiveImg();
  173. el.className = `${el.className} is-active`;
  174. this.activeResize(el);
  175. return;
  176. }
  177. },
  178. clearActiveImg() {
  179. this.$refs.editor.querySelectorAll("img").forEach((imgDom) => {
  180. if (!imgDom.className.indexOf("is-active")) return;
  181. let names = imgDom.className
  182. .split(" ")
  183. .filter((item) => item && item !== "is-active");
  184. imgDom.className = names.join(" ");
  185. });
  186. },
  187. clearResizeDom() {
  188. let doms = this.$refs.editorMain.querySelectorAll(".resize-elem");
  189. if (!doms.length) return;
  190. doms.forEach((dom) => {
  191. dom.parentNode.removeChild(dom);
  192. });
  193. },
  194. activeResize(imgDom) {
  195. const _this = this;
  196. let _x = 0;
  197. let { clientWidth, naturalWidth, naturalHeight } = imgDom;
  198. let resizeDom = null;
  199. removeResizeDom();
  200. createResizeDom();
  201. function createResizeDom() {
  202. resizeDom = document.createElement("div");
  203. resizeDom.className = "resize-elem";
  204. resizeDom.style.position = "absolute";
  205. resizeDom.style.width = "16px";
  206. resizeDom.style.height = "16px";
  207. resizeDom.style.backgroundColor = "#fff";
  208. resizeDom.style.backgroundImage =
  209. "url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAX1JREFUWEftlTFLw1AQx+/S2dEvUbe8g05CHbroKm7iIHQW1LlxraAfQHDoKDjqJNhBhJJ32SzOrvoRSv/yJIEaoq8kxi7JFAi534//e3fHtOKHV8ynRqBJoEnAm4Ax5jFt1Xdmnqbvb8z8Gsfxc9U2XlagWwAaq+rWfwjcEdF2DjQNgmAvjuOX2gVEZAjgdBHEzA/W2l5VuPvfewQiEgEY5GHMfGut3a0q4RUIw/CImS+JaArghpmjDMrM19bawyoSXgER6c3n86jVavXdmYdhGDHzYiIjVT0oK+EVKCqclwAQJUlyVkailIADFdyNb23Z6XTak8kkmxs/upUWSCX2AYwWqn9JiMjApUJE66r68VsylQRc4YI7MSaibHBtqupTrQKuuDHmhIjO8yAA/SRJrmoXSCXczsiP7AtVPa5doOAYMua9qu7UKiAiXQDZxsyzvAur8iXMiK7tZrPZBoB2EAQGwJr75tuYfyZQZggttYzKFl72vyaBJoEmgU/j14shnky4jQAAAABJRU5ErkJggg==')";
  210. resizeDom.style.backgroundSize = "100% 100%";
  211. resizeDom.style.borderTop = "1px solid #1886fe";
  212. resizeDom.style.borderLeft = "1px solid #1886fe";
  213. resizeDom.style.cursor = "nwse-resize";
  214. resizeDom.style.zIndex = 9999;
  215. _this.$refs.editorMain.appendChild(resizeDom);
  216. resizeResizeDom();
  217. }
  218. function removeResizeDom() {
  219. _this.clearResizeDom();
  220. resizeDom = null;
  221. }
  222. function resizeResizeDom() {
  223. const imgPos = imgDom.getBoundingClientRect();
  224. const mainPos = _this.$refs.editorMain.getBoundingClientRect();
  225. let relativeLeft = imgPos.x - mainPos.x;
  226. let relativeTop = imgPos.y - mainPos.y;
  227. resizeDom.style.left = imgPos.width + relativeLeft - 16 + "px";
  228. resizeDom.style.top = imgPos.height + relativeTop - 16 + "px";
  229. }
  230. function getValidSize(originSize, size) {
  231. if (size <= 10) return Math.min(originSize, 10);
  232. if (size >= 2 * originSize) return 2 * originSize;
  233. return size;
  234. }
  235. // 只允许鼠标左键触发
  236. function moveHandle(e) {
  237. if (e.button !== 0) return;
  238. e.preventDefault();
  239. e.stopPropagation();
  240. let width = getValidSize(naturalWidth, e.pageX - _x + clientWidth);
  241. let height = (width * naturalHeight) / naturalWidth;
  242. imgDom.style.width = width + "px";
  243. imgDom.style.height = height + "px";
  244. resizeResizeDom();
  245. }
  246. function upHandle(e) {
  247. _this.emitJsonStrict();
  248. if (e.button !== 0) return;
  249. e.preventDefault();
  250. e.stopPropagation();
  251. document.removeEventListener("mousemove", moveHandle);
  252. document.removeEventListener("mouseup", upHandle);
  253. }
  254. resizeDom.addEventListener("mousedown", function (e) {
  255. if (e.button !== 0) return;
  256. _x = e.pageX;
  257. e.preventDefault();
  258. e.stopPropagation();
  259. document.addEventListener("mousemove", moveHandle);
  260. document.addEventListener("mouseup", upHandle);
  261. });
  262. },
  263. editorBlur() {
  264. this.isFocus = false;
  265. this.clearActiveImg();
  266. this.clearResizeDom();
  267. },
  268. },
  269. };
  270. </script>
  271. <style>
  272. .v-editor {
  273. position: relative;
  274. line-height: 20px;
  275. }
  276. .v-editor-head {
  277. position: absolute;
  278. left: 2px;
  279. right: 2px;
  280. background-color: #fff;
  281. top: 2px;
  282. z-index: 9;
  283. border-bottom: 1px solid #f0f0f0;
  284. }
  285. .v-editor-container {
  286. border: 1px solid #e4e7ed;
  287. border-radius: 5px;
  288. min-height: 100px;
  289. max-height: 300px;
  290. padding: 26px 0 0 0;
  291. overflow: auto;
  292. white-space: pre-wrap;
  293. }
  294. .v-editor-container.is-focus {
  295. border-color: #1886fe;
  296. }
  297. .v-editor-main {
  298. position: relative;
  299. min-height: 100%;
  300. }
  301. .v-editor-body {
  302. outline: none;
  303. min-height: 100%;
  304. padding: 8px;
  305. }
  306. .v-editor-body[contenteditable="true"]:empty:not(:focus)::before {
  307. content: attr(data-placeholder);
  308. color: grey;
  309. }
  310. .v-editor-body div {
  311. min-height: 18px;
  312. line-height: 20px;
  313. }
  314. .v-editor-body img {
  315. max-width: 100%;
  316. }
  317. .v-editor-body img[data-is-answer-point] {
  318. max-height: 16px;
  319. display: inline-block;
  320. vertical-align: text-top;
  321. border-bottom: 1px solid #000;
  322. }
  323. .v-editor-body img.audio {
  324. height: 16px;
  325. padding: 0 2px;
  326. display: inline-block;
  327. margin-bottom: 4px;
  328. }
  329. .v-editor-body img[data-is-image] {
  330. /* max-height: 42px; */
  331. display: inline-block;
  332. }
  333. .v-editor-body img[data-is-image].is-active {
  334. box-shadow: 0 0 1px 1px #1886fe;
  335. }
  336. .v-editor-body audio {
  337. height: 16px;
  338. width: 180px;
  339. display: inline-block;
  340. margin-bottom: -4px;
  341. }
  342. .v-editor-body ::-webkit-media-controls-mute-button {
  343. display: none !important;
  344. }
  345. .v-editor-body ::-webkit-media-controls-volume-slider {
  346. display: none !important;
  347. }
  348. .v-editor-body b {
  349. font-weight: bold;
  350. }
  351. .v-editor-body i {
  352. font-style: italic;
  353. }
  354. .v-editor-body u {
  355. text-decoration: underline;
  356. }
  357. </style>