message-input-editor.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. <template>
  2. <div
  3. :class="['message-input-container', isH5 && 'message-input-container-h5']"
  4. >
  5. <div class="message-input-mute" v-show="isMute">
  6. {{ $t(`TUIChat.${muteText}`) }}
  7. </div>
  8. <editor-content
  9. v-show="!isMute && enableInput"
  10. :editor="editor"
  11. class="message-input-area"
  12. ref="editorContainer"
  13. @drop="(e:any) => handleFileDropOrPaste(e, 'drop')"
  14. @paste="(e:any) => handleFileDropOrPaste(e, 'paste')"
  15. @keydown.enter="handleEnter"
  16. />
  17. </div>
  18. </template>
  19. <script setup lang="ts">
  20. import { defineProps, defineEmits, toRefs, ref, defineExpose } from 'vue';
  21. import { useEditor, EditorContent } from '@tiptap/vue-3';
  22. import Document from '@tiptap/extension-document';
  23. import Paragraph from '@tiptap/extension-paragraph';
  24. import Placeholder from '@tiptap/extension-placeholder';
  25. import Text from '@tiptap/extension-text';
  26. import Mention from '@tiptap/extension-mention';
  27. import CustomImage from './message-input-file';
  28. import { MessageInputAtSuggestion } from './message-input-at.vue';
  29. const props = defineProps({
  30. placeholder: {
  31. type: String,
  32. default: 'this is placeholder'
  33. },
  34. replayOrReferenceMessage: {
  35. type: Object,
  36. default: () => ({})
  37. },
  38. isMute: {
  39. type: Boolean,
  40. default: true
  41. },
  42. muteText: {
  43. type: String,
  44. default: ''
  45. },
  46. enableInput: {
  47. type: Boolean,
  48. default: true
  49. },
  50. enableAt: {
  51. type: Boolean,
  52. default: true
  53. },
  54. enableDragUpload: {
  55. type: Boolean,
  56. default: true
  57. },
  58. enableTyping: {
  59. type: Boolean,
  60. default: true
  61. },
  62. isH5: {
  63. type: Boolean,
  64. default: true
  65. },
  66. isGroup: {
  67. type: Boolean,
  68. default: false
  69. }
  70. });
  71. const emits = defineEmits(['sendMessage', 'onTyping']);
  72. const { placeholder, isH5, enableAt, enableDragUpload, isGroup, enableTyping } =
  73. toRefs(props);
  74. const inputContentEmpty = ref(true);
  75. const inputBlur = ref(true);
  76. const editor = useEditor({
  77. extensions: [
  78. Document,
  79. Paragraph,
  80. Text,
  81. Placeholder.configure({
  82. emptyEditorClass: 'is-editor-empty',
  83. placeholder: placeholder.value
  84. }),
  85. Mention.configure({
  86. HTMLAttributes: {
  87. class: 'mention'
  88. },
  89. suggestion: enableAt.value && (MessageInputAtSuggestion() as any)
  90. }),
  91. CustomImage.configure({
  92. inline: true,
  93. allowBase64: true,
  94. HTMLAttributes: {
  95. class: 'custom-image'
  96. }
  97. })
  98. ],
  99. autofocus: true,
  100. editable: true,
  101. injectCSS: false,
  102. // handle input edtor typing (only in C2C and enable typing)
  103. onUpdate({ editor, transaction }) {
  104. if (!enableTyping.value || isGroup.value) return;
  105. inputBlur.value = !editor.isFocused;
  106. if (transaction?.doc?.content?.size > 2) {
  107. inputContentEmpty.value = false;
  108. } else {
  109. inputContentEmpty.value = true;
  110. }
  111. emits('onTyping', inputContentEmpty.value, inputBlur.value);
  112. },
  113. onFocus() {
  114. if (isH5.value && document?.getElementById('app')?.style) {
  115. // set app height when keyboard popup
  116. const keyboardHeight = document.body.scrollHeight - window.innerHeight;
  117. (
  118. document.getElementById('app') as any
  119. ).style.marginBottom = `${keyboardHeight}Px`;
  120. (
  121. document.getElementById('app') as any
  122. ).style.height = `calc(100% - ${keyboardHeight}Px)`;
  123. }
  124. if (!enableTyping.value || isGroup.value) return;
  125. inputBlur.value = true;
  126. emits('onTyping', inputContentEmpty.value, inputBlur.value);
  127. },
  128. onBlur() {
  129. if (isH5.value && document?.getElementById('app')?.style) {
  130. // reset app height to normal
  131. (document.getElementById('app') as any).style.marginBottom = ``;
  132. (document.getElementById('app') as any).style.height = `100%`;
  133. }
  134. if (!enableTyping.value || isGroup.value) return;
  135. inputBlur.value = true;
  136. emits('onTyping', inputContentEmpty.value, inputBlur.value);
  137. }
  138. });
  139. const editorContainer = ref();
  140. const handleEnter = (e: any) => {
  141. if (isH5?.value) {
  142. return;
  143. }
  144. e?.preventDefault();
  145. e?.stopPropagation();
  146. if (e.keyCode === 13 && e.ctrlKey) {
  147. // ctrl + enter: warp
  148. editor?.value?.commands?.insertContent('<p></p>');
  149. } else if (e.keyCode === 13) {
  150. // enter only: send message
  151. emits('sendMessage');
  152. }
  153. };
  154. // fileMap 存储 fileURL 与 fileObject 的映射
  155. const fileMap = new Map<string, any>();
  156. const handleFileDropOrPaste = async (e: any, type: string) => {
  157. e.preventDefault();
  158. e.stopPropagation();
  159. if (isH5.value) {
  160. return;
  161. }
  162. if (!enableDragUpload?.value && type === 'drop') {
  163. return;
  164. }
  165. if (
  166. (type === 'drop' && e.dataTransfer) ||
  167. (type === 'paste' && e.clipboardData)
  168. ) {
  169. const files =
  170. type === 'drop' ? e?.dataTransfer?.files : e?.clipboardData?.files;
  171. for (let i = 0; i < files.length; i++) {
  172. const file = files[i];
  173. const isImage = file.type.startsWith('image/');
  174. const fileSrc = isImage
  175. ? URL.createObjectURL(file)
  176. : await drawFileCanvasToImageUrl(file);
  177. editor?.value?.commands?.insertContent({
  178. type: 'custom-image',
  179. attrs: {
  180. src: fileSrc,
  181. alt: file?.name,
  182. title: file?.name,
  183. class: isImage ? 'normal' : 'file'
  184. }
  185. });
  186. fileMap.set(fileSrc, file);
  187. if (i === files.length - 1) {
  188. setTimeout(() => {
  189. editor?.value?.commands?.focus('end');
  190. editor?.value?.commands?.scrollIntoView();
  191. }, 10);
  192. }
  193. }
  194. }
  195. };
  196. // create file icon image
  197. // 为了避免重复创建拥有相同icon图标的img dom,将之前已有类型进行记录
  198. // 记录格式为 map<icon type,img dom>
  199. const fileIconDomMap = new Map<string, HTMLImageElement>();
  200. const addImageProcess = (src: string, type: string) => {
  201. return new Promise((resolve, reject) => {
  202. if (fileIconDomMap.has(type)) {
  203. resolve(fileIconDomMap.get(type));
  204. } else {
  205. let img = new Image();
  206. img.crossOrigin = 'anonymous';
  207. img.onload = () => {
  208. fileIconDomMap.set(type, img);
  209. resolve(img);
  210. };
  211. img.onerror = reject;
  212. img.src = src;
  213. }
  214. });
  215. };
  216. // draw file tag canvas
  217. const drawFileCanvasToImageUrl = async (file: any) => {
  218. const { name, type } = file;
  219. const canvas = document.createElement('canvas');
  220. let width = 160;
  221. let height = 50;
  222. canvas.style.width = width + 'Px';
  223. canvas.style.height = height + 'Px';
  224. // 设置内存中的实际大小(缩放以考虑额外的像素密度)
  225. let scale = window.devicePixelRatio; // 在视网膜屏幕上更改为 1 以查看模糊
  226. canvas.width = Math.floor(width * scale);
  227. canvas.height = Math.floor(height * scale);
  228. const ctx = canvas.getContext('2d');
  229. if (!ctx) {
  230. return '';
  231. }
  232. // 标准化坐标系以使用 css 像素
  233. ctx.scale(scale, scale);
  234. // draw icon
  235. const { iconSrc, iconType } = handleFileIconForShow(type);
  236. const img = await addImageProcess(iconSrc, iconType);
  237. ctx?.drawImage(img as any, 10, 10, 30, 30);
  238. // draw font
  239. const nameForShow = handleNameForShow(name);
  240. ctx.fillText(nameForShow, 45, 22);
  241. // canvas to url
  242. const dataURL = canvas.toDataURL();
  243. return dataURL;
  244. };
  245. const handleFileIconForShow = (type: string) => {
  246. const urlBase = 'https://web.sdk.qcloud.com/component/TUIKit/assets/file-';
  247. const fileTypes = [
  248. 'image',
  249. 'pdf',
  250. 'text',
  251. 'ppt',
  252. 'presentation',
  253. 'sheet',
  254. 'zip',
  255. 'word',
  256. 'video',
  257. 'unknown'
  258. ];
  259. let url = '';
  260. let iconType = '';
  261. fileTypes.forEach((typeName: string) => {
  262. if (type.includes(typeName)) {
  263. url = urlBase + typeName + '.svg';
  264. iconType = typeName;
  265. }
  266. });
  267. return {
  268. iconSrc: url ? url : urlBase + 'unknown.svg',
  269. iconType: iconType ? iconType : 'unknown'
  270. };
  271. };
  272. // 获取字符串的实际占位长度(字母or符号:1,其他(主要是中文):)
  273. const handleNameForShow = (value: string): string => {
  274. if (!value) {
  275. return value;
  276. }
  277. let res = '';
  278. let length = 0;
  279. for (let i = 0; i < value?.length; i++) {
  280. if (length > 16) {
  281. res += '...';
  282. break;
  283. }
  284. res += value[i];
  285. if (/[a-z]|[0-9]|[,;.!@#-+/\\$%^*()<>?:"'{}~]/i.test(value[i])) {
  286. length += 1;
  287. } else {
  288. length += 2;
  289. }
  290. }
  291. return res;
  292. };
  293. const getEditorContent = () => {
  294. return handleEditorForMessage();
  295. };
  296. const handleEditorForMessage = () => {
  297. const editorJSON = editor?.value?.getJSON();
  298. const content: any[] = [];
  299. const handleEditorContent = (root: any) => {
  300. if (!root || !root.type) {
  301. return;
  302. } else if (
  303. root.type !== 'text' &&
  304. root.type !== 'custom-image' &&
  305. root.type !== 'mention'
  306. ) {
  307. if (root.type === 'paragraph') {
  308. handleEditorNode(root);
  309. }
  310. if (root.content && root.content.length) {
  311. root.content.forEach((item: any) => {
  312. handleEditorContent(item);
  313. });
  314. }
  315. return;
  316. } else {
  317. handleEditorNode(root);
  318. }
  319. };
  320. const handleEditorNode = (node: any) => {
  321. // handle enter
  322. if (node.type === 'paragraph') {
  323. if (
  324. content.length > 0 &&
  325. content[content.length - 1] &&
  326. content[content.length - 1]?.type === 'text'
  327. ) {
  328. content[content.length - 1].payload.text += '\n';
  329. }
  330. } else if (
  331. node.type === 'text' ||
  332. (node.type === 'custom-image' && node?.attrs?.class === 'emoji')
  333. ) {
  334. const text = node.type === 'text' ? node?.text : node?.attrs?.alt;
  335. if (
  336. content.length > 0 &&
  337. content[content.length - 1] &&
  338. content[content.length - 1]?.type === 'text'
  339. ) {
  340. content[content.length - 1].payload.text += text;
  341. } else {
  342. content.push({
  343. type: 'text',
  344. payload: { text: text }
  345. });
  346. }
  347. } else if (
  348. node.type === 'custom-image' &&
  349. node?.attrs?.class === 'normal'
  350. ) {
  351. content.push({
  352. type: 'image',
  353. payload: { file: fileMap?.get(node?.attrs?.src) }
  354. });
  355. } else if (node.type === 'custom-image' && node?.attrs?.class === 'file') {
  356. const file = fileMap?.get(node?.attrs?.src);
  357. content.push({
  358. type: file?.type?.includes('video') ? 'video' : 'file',
  359. payload: { file }
  360. });
  361. } else if (node.type === 'mention') {
  362. const text = '@' + node?.attrs?.label + ' ';
  363. if (
  364. content.length > 0 &&
  365. content[content.length - 1] &&
  366. content[content.length - 1]?.type === 'text'
  367. ) {
  368. content[content.length - 1].payload.text += text;
  369. } else {
  370. content.push({
  371. type: 'text',
  372. payload: { text: text }
  373. });
  374. }
  375. if (content[content.length - 1]?.payload?.atUserList) {
  376. content[content.length - 1]?.payload?.atUserList?.push(node?.attrs?.id);
  377. } else {
  378. content[content.length - 1].payload.atUserList = [node?.attrs?.id];
  379. }
  380. }
  381. };
  382. handleEditorContent(editorJSON);
  383. if (
  384. content.length > 0 &&
  385. content[content.length - 1] &&
  386. content[content.length - 1]?.type === 'text' &&
  387. content[content.length - 1]?.payload?.text?.endsWith('\n')
  388. ) {
  389. const text = content[content.length - 1].payload.text;
  390. content[content.length - 1].payload.text = text?.substring(
  391. 0,
  392. text.lastIndexOf('\n')
  393. );
  394. }
  395. return content;
  396. };
  397. const addEmoji = (emoji: any) => {
  398. editor?.value?.commands?.insertContent({
  399. type: 'custom-image',
  400. attrs: {
  401. src: emoji?.url,
  402. alt: emoji?.name,
  403. title: emoji?.name,
  404. class: 'emoji'
  405. }
  406. });
  407. editor?.value?.commands.focus('end');
  408. editor?.value?.commands?.scrollIntoView();
  409. };
  410. const resetEditor = () => {
  411. editor?.value?.commands?.clearContent(true);
  412. fileMap?.clear();
  413. editor?.value?.commands?.focus('end');
  414. inputBlur.value = true;
  415. inputContentEmpty.value = true;
  416. };
  417. const setEditorContent = (content: any) => {
  418. editor?.value?.commands?.insertContent(content);
  419. };
  420. defineExpose({
  421. getEditorContent,
  422. addEmoji,
  423. resetEditor,
  424. setEditorContent
  425. });
  426. </script>
  427. <style scoped lang="scss">
  428. @import url('../../../styles/common.scss');
  429. @import url('../../../styles/icon.scss');
  430. .message-input {
  431. &-container {
  432. display: flex;
  433. flex-direction: column;
  434. flex: 1;
  435. height: calc(100% - 13Px);
  436. width: calc(100% - 20Px);
  437. padding: 3Px 10Px 10Px 10Px;
  438. overflow: hidden;
  439. }
  440. &-area {
  441. flex: 1;
  442. display: flex;
  443. overflow-y: scroll;
  444. &::-webkit-scrollbar {
  445. background: transparent;
  446. }
  447. }
  448. &-mute {
  449. flex: 1;
  450. display: flex;
  451. color: #999999;
  452. font-size: 14Px;
  453. justify-content: center;
  454. align-items: center;
  455. }
  456. }
  457. .message-input-container-h5 {
  458. flex: 1;
  459. height: auto;
  460. background: #f4f5f9;
  461. border-radius: 9.4Px;
  462. padding: 7Px 0Px 7Px 10Px;
  463. font-size: 16Px !important;
  464. max-height: 86Px;
  465. margin-right: 7Px;
  466. }
  467. </style>
  468. <style lang="scss">
  469. .ProseMirror {
  470. min-height: 100%;
  471. height: fit-content;
  472. flex: 1;
  473. font-size: 14Px;
  474. word-wrap: break-word;
  475. word-break: break-all;
  476. white-space: pre-wrap;
  477. div,
  478. ul,
  479. ol,
  480. dl,
  481. dt,
  482. dd,
  483. li,
  484. dl,
  485. h1,
  486. h2,
  487. h3,
  488. h4,
  489. p {
  490. margin: 0;
  491. padding: 0;
  492. font-style: normal;
  493. }
  494. p {
  495. * {
  496. vertical-align: bottom;
  497. }
  498. }
  499. -webkit-user-select: text;
  500. user-select: text;
  501. &-focused {
  502. border: none;
  503. outline: none;
  504. }
  505. img {
  506. &.ProseMirror-selectednode {
  507. outline: 2Px solid #68cef8;
  508. }
  509. }
  510. .custom-image {
  511. &-normal {
  512. max-height: 120Px;
  513. max-width: 200Px;
  514. }
  515. &-file {
  516. height: 50Px;
  517. width: 160Px;
  518. border: 1Px solid #e8e8e9;
  519. border-radius: 5Px;
  520. }
  521. &-emoji {
  522. height: 20Px;
  523. width: 20Px;
  524. }
  525. }
  526. .ProseMirror-selectednode {
  527. outline: 2Px solid #68cef8;
  528. cursor: none;
  529. }
  530. p,
  531. [contenteditable] {
  532. -webkit-user-select: text;
  533. user-select: text;
  534. }
  535. // placeholder style
  536. p.is-editor-empty:first-child::before {
  537. color: #adb5bd;
  538. content: attr(data-placeholder);
  539. float: left;
  540. height: 0;
  541. pointer-events: none;
  542. }
  543. }
  544. </style>