ChatInput.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. <template>
  2. <div class="chat-input-area">
  3. <div :class="['edit-chat-container', isEmpty ? '' : 'not-empty']" contenteditable="true"
  4. @paste.prevent="onPaste" @keydown="onKeydown" @compositionstart="compositionFlag = true"
  5. @compositionend="onCompositionEnd" @input="onEditorInput" @mousedown="onMousedown" ref="content"
  6. @blur="onBlur">
  7. </div>
  8. <chat-at-box @select="onAtSelect" :search-text="atSearchText" ref="atBox" :ownerId="ownerId"
  9. :members="groupMembers"></chat-at-box>
  10. </div>
  11. </template>
  12. <script>
  13. import ChatAtBox from "./ChatAtBox";
  14. export default {
  15. name: "ChatInput",
  16. components: { ChatAtBox },
  17. props: {
  18. ownerId: {
  19. type: Number,
  20. },
  21. groupMembers: {
  22. type: Array,
  23. },
  24. },
  25. data() {
  26. return {
  27. imageList: [],
  28. fileList: [],
  29. currentId: 0,
  30. atSearchText: null,
  31. compositionFlag: false,
  32. atIng: false,
  33. isEmpty: true,
  34. changeStored: true,
  35. blurRange: null
  36. }
  37. },
  38. methods: {
  39. onPaste(e) {
  40. this.isEmpty = false;
  41. let txt = e.clipboardData.getData('Text')
  42. let range = window.getSelection().getRangeAt(0)
  43. if (range.startContainer !== range.endContainer || range.startOffset !== range.endOffset) {
  44. range.deleteContents();
  45. }
  46. // 粘贴图片和文件时,这里没有数据
  47. if (txt && typeof(txt) == 'string') {
  48. let textNode = document.createTextNode(txt);
  49. range.insertNode(textNode)
  50. range.collapse();
  51. return;
  52. }
  53. let items = (e.clipboardData || window.clipboardData).items
  54. if (items.length) {
  55. for (let i = 0; i < items.length; i++) {
  56. if (items[i].type.indexOf('image') !== -1) {
  57. let file = items[i].getAsFile();
  58. let imagePush = {
  59. fileId: this.generateId(),
  60. file: file,
  61. url: URL.createObjectURL(file)
  62. };
  63. this.imageList[imagePush.fileId] = (imagePush);
  64. let line = this.newLine();
  65. let imageElement = document.createElement('img');
  66. imageElement.className = 'chat-image no-text';
  67. imageElement.src = imagePush.url;
  68. imageElement.dataset.imgId = imagePush.fileId;
  69. line.appendChild(imageElement);
  70. let after = document.createTextNode('\u00A0');
  71. line.appendChild(after);
  72. this.selectElement(after, 1);
  73. } else {
  74. let asFile = items[i].getAsFile();
  75. if (!asFile) {
  76. continue;
  77. }
  78. let filePush = { fileId: this.generateId(), file: asFile };
  79. this.fileList[filePush.fileId] = (filePush)
  80. let line = this.newLine();
  81. let fileElement = this.createFile(filePush);
  82. line.appendChild(fileElement);
  83. let after = document.createTextNode('\u00A0');
  84. line.appendChild(after);
  85. this.selectElement(after, 1);
  86. }
  87. }
  88. }
  89. range.collapse();
  90. },
  91. selectElement(element, endOffset) {
  92. let selection = window.getSelection();
  93. // 插入元素可能不是立即执行的,vue可能会在插入元素后再更新dom
  94. this.$nextTick(() => {
  95. let t1 = document.createRange();
  96. t1.setStart(element, 0);
  97. t1.setEnd(element, endOffset || 0);
  98. if (element.firstChild) {
  99. t1.selectNodeContents(element.firstChild);
  100. }
  101. t1.collapse();
  102. selection.removeAllRanges();
  103. selection.addRange(t1);
  104. // 需要时自动聚焦
  105. if (element.focus) {
  106. element.focus();
  107. }
  108. })
  109. },
  110. onCompositionEnd(e) {
  111. this.compositionFlag = false;
  112. this.onEditorInput(e);
  113. },
  114. onKeydown(e) {
  115. if (e.keyCode === 13) {
  116. e.preventDefault();
  117. e.stopPropagation();
  118. if (this.atIng) {
  119. this.$refs.atBox.select();
  120. return;
  121. }
  122. if (e.ctrlKey) {
  123. let line = this.newLine();
  124. let after = document.createTextNode('\u00A0');
  125. line.appendChild(after);
  126. this.selectElement(line.childNodes[0], 0);
  127. } else {
  128. // 中文输入标记
  129. if (this.compositionFlag) {
  130. return;
  131. }
  132. this.submit();
  133. }
  134. return;
  135. }
  136. // 删除键
  137. if (e.keyCode === 8) {
  138. // 等待dom更新
  139. setTimeout(() => {
  140. let s = this.$refs.content.innerHTML.trim();
  141. // 空dom时,需要刷新dom
  142. if (s === '' || s === '<br>' || s === '<div>&nbsp;</div>') {
  143. // 拼接随机长度的空格,以刷新dom
  144. this.empty();
  145. this.isEmpty = true;
  146. this.selectElement(this.$refs.content);
  147. } else {
  148. this.isEmpty = false;
  149. }
  150. })
  151. }
  152. // at框打开时,上下键移动特殊处理
  153. if (this.atIng) {
  154. if (e.keyCode === 38) {
  155. e.preventDefault();
  156. e.stopPropagation();
  157. this.$refs.atBox.moveUp();
  158. }
  159. if (e.keyCode === 40) {
  160. e.preventDefault();
  161. e.stopPropagation();
  162. this.$refs.atBox.moveDown();
  163. }
  164. }
  165. },
  166. onAtSelect(member) {
  167. this.atIng = false;
  168. // 选中输入的 @xx 符
  169. let blurRange = this.blurRange;
  170. let endContainer = blurRange.endContainer
  171. let startOffset = endContainer.data.indexOf("@" + this.atSearchText);
  172. let endOffset = startOffset + this.atSearchText.length + 1;
  173. blurRange.setStart(blurRange.endContainer, startOffset);
  174. blurRange.setEnd(blurRange.endContainer, endOffset);
  175. blurRange.deleteContents()
  176. blurRange.collapse();
  177. this.focus();
  178. // 创建元素节点
  179. let element = document.createElement('SPAN')
  180. element.className = "chat-at-user";
  181. element.dataset.id = member.userId;
  182. element.contentEditable = 'false'
  183. element.innerText = `@${member.showNickName}`
  184. blurRange.insertNode(element)
  185. // 光标移动到末尾
  186. blurRange.collapse()
  187. // 插入空格
  188. let textNode = document.createTextNode('\u00A0');
  189. blurRange.insertNode(textNode);
  190. blurRange.collapse()
  191. this.atSearchText = "";
  192. this.selectElement(textNode, 1);
  193. },
  194. onEditorInput(e) {
  195. this.isEmpty = false;
  196. this.changeStored = false;
  197. if (this.$props.groupMembers && !this.compositionFlag) {
  198. let selection = window.getSelection()
  199. let range = selection.getRangeAt(0);
  200. // 截取@后面的名称作为过滤条件,并以空格结束
  201. let endContainer = range.endContainer;
  202. let endOffset = range.endOffset;
  203. let textContent = endContainer.textContent;
  204. let startIndex = -1;
  205. for (let i = endOffset; i >= 0; i--) {
  206. if (textContent[i] === '@') {
  207. startIndex = i;
  208. break;
  209. }
  210. }
  211. // 没有at符号,则关闭弹窗
  212. if (startIndex === -1) {
  213. this.$refs.atBox.close();
  214. return;
  215. }
  216. let endIndex = endOffset;
  217. for (let i = endOffset; i < textContent.length; i++) {
  218. if (textContent[i] === ' ') {
  219. endIndex = i;
  220. break;
  221. }
  222. }
  223. this.atSearchText = textContent.substring(startIndex + 1, endIndex).trim();
  224. // 打开选择弹窗
  225. if (this.atSearchText == '') {
  226. this.showAtBox(e)
  227. }
  228. }
  229. },
  230. onBlur(e) {
  231. if (!this.atIng) {
  232. this.updateRange();
  233. }
  234. },
  235. onMousedown() {
  236. if (this.atIng) {
  237. this.$refs.atBox.close();
  238. this.atIng = false;
  239. }
  240. },
  241. insertEmoji(emojiText) {
  242. let emojiElement = document.createElement('img');
  243. emojiElement.className = 'emoji-normal no-text';
  244. emojiElement.dataset.emojiCode = emojiText;
  245. emojiElement.src = this.$emo.textToUrl(emojiText);
  246. let blurRange = this.blurRange;
  247. if (!blurRange) {
  248. this.focus();
  249. this.updateRange();
  250. blurRange = this.blurRange;
  251. }
  252. if (blurRange.startContainer !== blurRange.endContainer || blurRange.startOffset !== blurRange.endOffset) {
  253. blurRange.deleteContents();
  254. }
  255. blurRange.insertNode(emojiElement);
  256. blurRange.collapse()
  257. let textNode = document.createTextNode('\u00A0');
  258. blurRange.insertNode(textNode)
  259. blurRange.collapse()
  260. this.selectElement(textNode);
  261. this.updateRange();
  262. this.isEmpty = false;
  263. },
  264. generateId() {
  265. return this.currentId++;
  266. },
  267. createFile(filePush) {
  268. let file = filePush.file;
  269. let fileId = filePush.fileId;
  270. let container = document.createElement('div');
  271. container.className = 'chat-file-container no-text';
  272. container.contentEditable = 'false';
  273. container.dataset.fileId = fileId;
  274. let left = document.createElement('div');
  275. left.className = 'file-position-left';
  276. container.appendChild(left);
  277. let icon = document.createElement('div');
  278. icon.className = 'el-icon-document';
  279. left.appendChild(icon);
  280. let right = document.createElement('div');
  281. right.className = 'file-position-right';
  282. container.appendChild(right);
  283. let fileName = document.createElement('div');
  284. fileName.className = 'file-name';
  285. fileName.innerText = file.name;
  286. let fileSize = document.createElement('div');
  287. fileSize.className = 'file-size';
  288. fileSize.innerText = this.sizeConvert(file.size);
  289. right.appendChild(fileName);
  290. right.appendChild(fileSize);
  291. return container;
  292. },
  293. sizeConvert(len) {
  294. if (len < 1024) {
  295. return len + 'B';
  296. } else if (len < 1024 * 1024) {
  297. return (len / 1024).toFixed(2) + 'KB';
  298. } else if (len < 1024 * 1024 * 1024) {
  299. return (len / 1024 / 1024).toFixed(2) + 'MB';
  300. } else {
  301. return (len / 1024 / 1024 / 1024).toFixed(2) + 'GB';
  302. }
  303. },
  304. updateRange() {
  305. let selection = window.getSelection();
  306. this.blurRange = selection.getRangeAt(0);
  307. },
  308. newLine() {
  309. let selection = window.getSelection();
  310. let range = selection.getRangeAt(0);
  311. let divElement = document.createElement('div');
  312. let endContainer = range.endContainer;
  313. let parentElement = endContainer.parentElement;
  314. if (parentElement.parentElement === this.$refs.content) {
  315. divElement.innerHTML = endContainer.textContent.substring(range.endOffset).trim();
  316. endContainer.textContent = endContainer.textContent.substring(0, range.endOffset);
  317. // 插入到当前div(当前行)后面
  318. parentElement.insertAdjacentElement('afterend', divElement);
  319. } else {
  320. divElement.innerHTML = '';
  321. this.$refs.content.append(divElement);
  322. }
  323. return divElement;
  324. },
  325. clear() {
  326. this.empty();
  327. this.imageList = [];
  328. this.fileList = [];
  329. this.$refs.atBox.close();
  330. },
  331. empty() {
  332. this.$refs.content.innerHTML = "";
  333. let line = this.newLine();
  334. let after = document.createTextNode('\u00A0');
  335. line.appendChild(after);
  336. this.$nextTick(() => this.selectElement(after));
  337. },
  338. showAtBox(e) {
  339. this.atIng = true;
  340. // show之后会自动更新当前搜索的text
  341. // this.atSearchText = "";
  342. let selection = window.getSelection()
  343. let range = selection.getRangeAt(0)
  344. // 光标所在坐标
  345. let pos = range.getBoundingClientRect();
  346. this.$refs.atBox.open({
  347. x: pos.x,
  348. y: pos.y
  349. })
  350. // 记录光标所在位置
  351. this.updateRange();
  352. },
  353. submit() {
  354. let nodes = this.$refs.content.childNodes;
  355. let fullList = [];
  356. let tempText = '';
  357. let atUserIds = [];
  358. let each = (nodes) => {
  359. for (let i = 0; i < nodes.length; i++) {
  360. let node = nodes[i];
  361. if (!node) {
  362. continue;
  363. }
  364. if (node.nodeType === 3) {
  365. tempText += node.textContent;
  366. continue;
  367. }
  368. let nodeName = node.nodeName.toLowerCase();
  369. if (nodeName === 'script') {
  370. continue;
  371. }
  372. let text = tempText.trim();
  373. if (nodeName === 'img') {
  374. let imgId = node.dataset.imgId;
  375. if (imgId) {
  376. if (text) {
  377. fullList.push({
  378. type: 'text',
  379. content: text,
  380. atUserIds: atUserIds
  381. })
  382. tempText = '';
  383. atUserIds = []
  384. }
  385. fullList.push({
  386. type: 'image',
  387. content: this.imageList[imgId]
  388. })
  389. } else {
  390. let emojiCode = node.dataset.emojiCode;
  391. tempText += emojiCode;
  392. }
  393. } else if (nodeName === 'div') {
  394. let fileId = node.dataset.fileId
  395. // 文件
  396. if (fileId) {
  397. if (text) {
  398. fullList.push({
  399. type: 'text',
  400. content: text,
  401. atUserIds: atUserIds
  402. })
  403. tempText = '';
  404. atUserIds = []
  405. }
  406. fullList.push({
  407. type: 'file',
  408. content: this.fileList[fileId]
  409. })
  410. } else {
  411. tempText += '\n';
  412. each(node.childNodes);
  413. }
  414. } else if (nodeName === 'span') {
  415. if (node.dataset.id) {
  416. tempText += node.innerHTML;
  417. atUserIds.push(node.dataset.id)
  418. } else if (node.outerHtml) {
  419. tempText += node.outerHtml;
  420. }
  421. }
  422. }
  423. }
  424. each(nodes)
  425. let text = tempText.trim();
  426. if (text !== '') {
  427. fullList.push({
  428. type: 'text',
  429. content: text,
  430. atUserIds: atUserIds
  431. })
  432. }
  433. this.$emit('submit', fullList);
  434. },
  435. focus() {
  436. this.$refs.content.focus();
  437. }
  438. }
  439. }
  440. </script>
  441. <style lang="scss">
  442. .chat-input-area {
  443. width: 100%;
  444. height: 100%;
  445. position: relative;
  446. .edit-chat-container {
  447. position: absolute;
  448. top: 0;
  449. left: 0;
  450. right: 0;
  451. bottom: 0;
  452. outline: none;
  453. padding: 5px;
  454. line-height: 26px;
  455. font-size: var(--im-font-size);
  456. text-align: left;
  457. overflow-y: auto;
  458. // 单独一行时,无法在前面输入的bug
  459. >div:before {
  460. content: "\00a0";
  461. font-size: 14px;
  462. position: absolute;
  463. top: 0;
  464. left: 0;
  465. }
  466. .chat-image {
  467. display: block;
  468. max-width: 200px;
  469. max-height: 100px;
  470. border: 1px solid #e6e6e6;
  471. cursor: pointer;
  472. }
  473. .chat-file-container {
  474. max-width: 65%;
  475. padding: 10px;
  476. border: 2px solid #587ff0;
  477. display: flex;
  478. background: #eeeC;
  479. border-radius: 10px;
  480. .file-position-left {
  481. display: flex;
  482. width: 80px;
  483. justify-content: center;
  484. align-items: center;
  485. .el-icon-document {
  486. font-size: 40px;
  487. text-align: center;
  488. color: #d42e07;
  489. }
  490. }
  491. .file-position-right {
  492. flex: 1;
  493. .file-name {
  494. font-size: 16px;
  495. font-weight: 600;
  496. color: #66b1ff;
  497. }
  498. .file-size {
  499. font-size: 14px;
  500. font-weight: 600;
  501. }
  502. }
  503. }
  504. .chat-at-user {
  505. color: #00f;
  506. border-radius: 3px;
  507. }
  508. }
  509. .edit-chat-container>div:nth-of-type(1):after {
  510. content: '请输入消息(按Ctrl+Enter键换行)';
  511. color: gray;
  512. }
  513. .edit-chat-container.not-empty>div:nth-of-type(1):after {
  514. content: none;
  515. }
  516. }
  517. </style>