editor.vue 14 KB

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