editor.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. <script>
  2. import { useScopedSlot, messageToHtml, clearHtmlExcludeImg } from "utils";
  3. const command = (command, val) => {
  4. document.execCommand(command, false, val);
  5. };
  6. const selection = window.getSelection();
  7. let range;
  8. let emojiData = [];
  9. let isInitTool = false;
  10. export default {
  11. name: "LemonEditor",
  12. inject: {
  13. IMUI: {
  14. from: "IMUI",
  15. default() {
  16. return this;
  17. },
  18. },
  19. },
  20. components: {},
  21. props: {
  22. tools: {
  23. type: Array,
  24. default: () => [],
  25. },
  26. sendText: {
  27. type: String,
  28. default: "发 送",
  29. },
  30. wrapKey: {
  31. type: Function,
  32. default: function(e) {
  33. return e.keyCode == 13 && e.ctrlKey == false && e.shiftKey == false;
  34. },
  35. },
  36. sendKey: {
  37. type: Function,
  38. default(e) {
  39. return e.keyCode == 13 && e.ctrlKey === true;
  40. },
  41. },
  42. },
  43. data() {
  44. this.clipboardBlob = null;
  45. return {
  46. //剪切板图片URL
  47. clipboardUrl: "",
  48. submitDisabled: true,
  49. //proxyTools: [],
  50. accept: "",
  51. };
  52. },
  53. created() {
  54. this.IMUI.$on("change-contact", () => {
  55. this.closeClipboardImage();
  56. });
  57. },
  58. render() {
  59. const toolLeft = [];
  60. const toolRight = [];
  61. this.proxyTools.forEach(({ name, title, render, click, isRight }) => {
  62. click = click || new Function();
  63. const classes = [
  64. "lemon-editor__tool-item",
  65. { "lemon-editor__tool-item--right": isRight },
  66. ];
  67. let node;
  68. if (name == "emoji") {
  69. node =
  70. emojiData.length == 0 ? (
  71. ""
  72. ) : (
  73. <lemon-popover class="lemon-editor__emoji">
  74. <template slot="content">{this._renderEmojiTabs()}</template>
  75. <div class={classes} title={title}>
  76. {render()}
  77. </div>
  78. </lemon-popover>
  79. );
  80. } else {
  81. node = (
  82. <div class={classes} on-click={click} title={title}>
  83. {render()}
  84. </div>
  85. );
  86. }
  87. if (isRight) {
  88. toolRight.push(node);
  89. } else {
  90. toolLeft.push(node);
  91. }
  92. });
  93. return (
  94. <div class="lemon-editor">
  95. {this.clipboardUrl && (
  96. <div class="lemon-editor__clipboard-image">
  97. <img src={this.clipboardUrl} />
  98. <div>
  99. <lemon-button
  100. style={{ marginRight: "10px" }}
  101. on-click={this.closeClipboardImage}
  102. color="grey"
  103. >
  104. 取消
  105. </lemon-button>
  106. <lemon-button on-click={this.sendClipboardImage}>
  107. 发送图片
  108. </lemon-button>
  109. </div>
  110. </div>
  111. )}
  112. <input
  113. style="display:none"
  114. type="file"
  115. multiple="multiple"
  116. ref="fileInput"
  117. accept={this.accept}
  118. onChange={this._handleChangeFile}
  119. />
  120. <div class="lemon-editor__tool">
  121. <div class="lemon-editor__tool-left">{toolLeft}</div>
  122. <div class="lemon-editor__tool-right">{toolRight}</div>
  123. </div>
  124. <div class="lemon-editor__inner">
  125. <div
  126. class="lemon-editor__input"
  127. ref="textarea"
  128. contenteditable="true"
  129. on-keyup={this._handleKeyup}
  130. on-keydown={this._handleKeydown}
  131. on-paste={this._handlePaste}
  132. on-click={this._handleClick}
  133. spellcheck="false"
  134. />
  135. </div>
  136. <div class="lemon-editor__footer">
  137. <div class="lemon-editor__tip">
  138. {useScopedSlot(
  139. this.IMUI.$scopedSlots["editor-footer"],
  140. "使用 ctrl + enter 快捷发送消息",
  141. )}
  142. </div>
  143. <div class="lemon-editor__submit">
  144. <lemon-button
  145. disabled={this.submitDisabled}
  146. on-click={this._handleSend}
  147. >
  148. {this.sendText}
  149. </lemon-button>
  150. </div>
  151. </div>
  152. </div>
  153. );
  154. },
  155. computed: {
  156. proxyTools() {
  157. console.log("this.tools", this.tools);
  158. if (!this.tools) return [];
  159. const defaultTools = [
  160. {
  161. name: "emoji",
  162. title: "表情",
  163. click: null,
  164. render: menu => {
  165. return <i class="lemon-icon-emoji" />;
  166. },
  167. },
  168. {
  169. name: "uploadFile",
  170. title: "文件上传",
  171. click: () => this.selectFile("*"),
  172. render: menu => {
  173. return <i class="lemon-icon-folder" />;
  174. },
  175. },
  176. {
  177. name: "uploadImage",
  178. title: "图片上传",
  179. click: () => this.selectFile("image/*"),
  180. render: menu => {
  181. return <i class="lemon-icon-image" />;
  182. },
  183. },
  184. ];
  185. let tools = [];
  186. if (Array.isArray(this.tools)) {
  187. const indexMap = {
  188. emoji: 0,
  189. uploadFile: 1,
  190. uploadImage: 2,
  191. };
  192. const indexKeys = Object.keys(indexMap);
  193. tools = this.tools.map(item => {
  194. if (indexKeys.includes(item.name)) {
  195. return {
  196. ...defaultTools[indexMap[item.name]],
  197. ...item,
  198. };
  199. }
  200. return item;
  201. });
  202. } else {
  203. tools = defaultTools;
  204. }
  205. return tools;
  206. },
  207. },
  208. methods: {
  209. closeClipboardImage() {
  210. this.clipboardUrl = "";
  211. this.clipboardBlob = null;
  212. },
  213. sendClipboardImage() {
  214. if (!this.clipboardBlob) return;
  215. this.$emit("upload", this.clipboardBlob);
  216. this.closeClipboardImage();
  217. },
  218. saveRangeToLast() {
  219. if (!range) {
  220. range = document.createRange();
  221. }
  222. range.selectNodeContents(textarea.value);
  223. range.collapse(false);
  224. selection.removeAllRanges();
  225. selection.addRange(range);
  226. },
  227. inertContent(val, toLast = false) {
  228. if (toLast) saveRangeToLast();
  229. this.focusRange();
  230. command("insertHTML", val);
  231. this.saveRange();
  232. },
  233. saveRange() {
  234. range = selection.getRangeAt(0);
  235. },
  236. focusRange() {
  237. this.$refs.textarea.focus();
  238. if (range) {
  239. selection.removeAllRanges();
  240. selection.addRange(range);
  241. }
  242. },
  243. _handleClick() {
  244. this.saveRange();
  245. },
  246. _renderEmojiTabs() {
  247. const renderImageGrid = items => {
  248. return items.map(item => (
  249. <img
  250. src={item.src}
  251. title={item.title}
  252. class="lemon-editor__emoji-item"
  253. on-click={() => this._handleSelectEmoji(item)}
  254. />
  255. ));
  256. };
  257. if (emojiData[0].label) {
  258. const nodes = emojiData.map((item, index) => {
  259. return (
  260. <div slot="tab-pane" index={index} tab={item.label}>
  261. {renderImageGrid(item.children)}
  262. </div>
  263. );
  264. });
  265. return <lemon-tabs style="width: 412px">{nodes}</lemon-tabs>;
  266. } else {
  267. return (
  268. <div class="lemon-tabs-content" style="width:406px">
  269. {renderImageGrid(emojiData)}
  270. </div>
  271. );
  272. }
  273. },
  274. _handleSelectEmoji(item) {
  275. this.inertContent(
  276. `<img emoji-name="${item.name}" src="${item.src}"></img>`,
  277. );
  278. this._checkSubmitDisabled();
  279. },
  280. async selectFile(accept) {
  281. this.accept = accept;
  282. await this.$nextTick();
  283. this.$refs.fileInput.click();
  284. },
  285. _handlePaste(e) {
  286. e.preventDefault();
  287. const clipboardData = e.clipboardData || window.clipboardData;
  288. const text = clipboardData.getData("Text");
  289. if (text) {
  290. if (window.clipboardData) {
  291. this.$refs.textarea.innerHTML = text;
  292. } else {
  293. command("insertText", text);
  294. }
  295. } else {
  296. const { blob, blobUrl } = this._getClipboardBlob(clipboardData);
  297. this.clipboardBlob = blob;
  298. this.clipboardUrl = blobUrl;
  299. }
  300. },
  301. _getClipboardBlob(clipboard) {
  302. let blob, blobUrl;
  303. for (var i = 0; i < clipboard.items.length; ++i) {
  304. if (
  305. clipboard.items[i].kind == "file" &&
  306. clipboard.items[i].type.indexOf("image/") !== -1
  307. ) {
  308. blob = clipboard.items[i].getAsFile();
  309. blobUrl = (window.URL || window.webkitURL).createObjectURL(blob);
  310. }
  311. }
  312. return { blob, blobUrl };
  313. },
  314. _handleKeyup(e) {
  315. this.saveRange();
  316. this._checkSubmitDisabled();
  317. },
  318. _handleKeydown(e) {
  319. const ATing = false;
  320. if (ATing) {
  321. if (e.keyCode == 38 || e.keyCode == 40) {
  322. e.preventDefault();
  323. if (e.keyCode == 38) {
  324. ATSelectedPrev();
  325. }
  326. if (e.keyCode == 40) {
  327. ATSelectedNext();
  328. }
  329. return;
  330. }
  331. if (e.keyCode == 13) {
  332. e.preventDefault();
  333. ATSelected();
  334. return;
  335. }
  336. if (e.keyCode == 37 || e.keyCode == 39) {
  337. ATPopupClose();
  338. }
  339. }
  340. if (e.keyCode == 13 || (e.keyCode == 13 && e.shiftKey)) {
  341. e.preventDefault();
  342. }
  343. if (this.wrapKey(e)) {
  344. e.preventDefault();
  345. command("insertLineBreak");
  346. }
  347. if (this.at && (e.key == "@" || (e.shiftKey && e.keyCode == 229))) {
  348. setTimeout(() => (ATing = true), 300);
  349. }
  350. if (this.submitDisabled == false && this.sendKey(e)) {
  351. this._handleSend();
  352. }
  353. },
  354. getFormatValue() {
  355. // return toEmojiName(
  356. // this.$refs.textarea.innerHTML
  357. // .replace(/<br>|<\/br>/, "")
  358. // .replace(/<div>|<p>/g, "\r\n")
  359. // .replace(/<\/div>|<\/p>/g, "")
  360. // );
  361. return this.IMUI.emojiImageToName(this.$refs.textarea.innerHTML);
  362. },
  363. _checkSubmitDisabled() {
  364. this.submitDisabled = !clearHtmlExcludeImg(
  365. this.$refs.textarea.innerHTML.trim(),
  366. );
  367. },
  368. _handleSend(e) {
  369. const text = this.getFormatValue();
  370. this.$emit("send", text);
  371. this.clear();
  372. this._checkSubmitDisabled();
  373. },
  374. _handleChangeFile(e) {
  375. const { fileInput } = this.$refs;
  376. Array.from(fileInput.files).forEach(file => {
  377. this.$emit("upload", file);
  378. });
  379. fileInput.value = "";
  380. },
  381. clear() {
  382. this.$refs.textarea.innerHTML = "";
  383. },
  384. initEmoji(data) {
  385. emojiData = data;
  386. this.$forceUpdate();
  387. },
  388. setValue(val) {
  389. this.$refs.textarea.innerHTML = this.IMUI.emojiNameToImage(val);
  390. this._checkSubmitDisabled();
  391. },
  392. },
  393. };
  394. </script>
  395. <style lang="stylus">
  396. @import '~styles/utils/index'
  397. gap = 10px;
  398. +b(lemon-editor)
  399. height 200px
  400. position relative
  401. flex-column()
  402. +e(tool)
  403. display flex
  404. height 40px
  405. align-items center
  406. justify-content space-between
  407. padding 0 5px
  408. +e(tool-left){
  409. display flex
  410. }
  411. +e(tool-right){
  412. display flex
  413. }
  414. +e(tool-item)
  415. cursor pointer
  416. padding 4px gap
  417. height 28px
  418. line-height 24px;
  419. color #999
  420. transition all ease .3s
  421. font-size 12px
  422. [class^='lemon-icon-']
  423. line-height 26px
  424. font-size 22px
  425. &:hover
  426. color #333
  427. +m(right){
  428. margin-left:auto;
  429. }
  430. +e(inner)
  431. flex 1
  432. overflow-x hidden
  433. overflow-y auto
  434. scrollbar-light()
  435. +e(clipboard-image)
  436. position absolute
  437. top 0
  438. left 0
  439. width 100%
  440. height 100%
  441. flex-column()
  442. justify-content center
  443. align-items center
  444. background #f4f4f4
  445. z-index 1
  446. img
  447. max-height 66%
  448. max-width 80%
  449. background #e9e9e9
  450. //box-shadow 0 0 20px rgba(0,0,0,0.15)
  451. user-select none
  452. cursor pointer
  453. border-radius 4px
  454. margin-bottom 10px
  455. border 3px dashed #ddd !important
  456. box-sizing border-box
  457. .clipboard-popover-title
  458. font-size 14px
  459. color #333
  460. +e(input)
  461. height 100%
  462. box-sizing border-box
  463. border none
  464. outline none
  465. padding 0 gap
  466. scrollbar-light()
  467. p,div
  468. margin 0
  469. img
  470. height 20px
  471. padding 0 2px
  472. pointer-events none
  473. position relative
  474. top -1px
  475. vertical-align middle
  476. +e(footer)
  477. display flex
  478. height 52px
  479. justify-content flex-end
  480. padding 0 gap
  481. align-items center
  482. +e(tip)
  483. margin-right 10px
  484. font-size 12px
  485. color #999
  486. user-select none
  487. +e(emoji)
  488. user-select none
  489. .lemon-popover
  490. background #f6f6f6
  491. .lemon-popover__content
  492. padding 0
  493. .lemon-popover__arrow
  494. background #f6f6f6
  495. .lemon-tabs-content
  496. box-sizing border-box
  497. padding 8px
  498. height 200px
  499. overflow-x hidden
  500. overflow-y auto
  501. scrollbar-light()
  502. margin-bottom 8px
  503. +e(emoji-item)
  504. cursor pointer
  505. width 22px
  506. padding 4px
  507. border-radius 4px
  508. &:hover
  509. background #e9e9e9
  510. </style>