123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565 |
- <template>
- <div
- :class="['message-input-container', isH5 && 'message-input-container-h5']"
- >
- <div class="message-input-mute" v-show="isMute">
- {{ $t(`TUIChat.${muteText}`) }}
- </div>
- <editor-content
- v-show="!isMute && enableInput"
- :editor="editor"
- class="message-input-area"
- ref="editorContainer"
- @drop="(e:any) => handleFileDropOrPaste(e, 'drop')"
- @paste="(e:any) => handleFileDropOrPaste(e, 'paste')"
- @keydown.enter="handleEnter"
- />
- </div>
- </template>
- <script setup lang="ts">
- import { defineProps, defineEmits, toRefs, ref, defineExpose } from 'vue';
- import { useEditor, EditorContent } from '@tiptap/vue-3';
- import Document from '@tiptap/extension-document';
- import Paragraph from '@tiptap/extension-paragraph';
- import Placeholder from '@tiptap/extension-placeholder';
- import Text from '@tiptap/extension-text';
- import Mention from '@tiptap/extension-mention';
- import CustomImage from './message-input-file';
- import { MessageInputAtSuggestion } from './message-input-at.vue';
- const props = defineProps({
- placeholder: {
- type: String,
- default: 'this is placeholder'
- },
- replayOrReferenceMessage: {
- type: Object,
- default: () => ({})
- },
- isMute: {
- type: Boolean,
- default: true
- },
- muteText: {
- type: String,
- default: ''
- },
- enableInput: {
- type: Boolean,
- default: true
- },
- enableAt: {
- type: Boolean,
- default: true
- },
- enableDragUpload: {
- type: Boolean,
- default: true
- },
- enableTyping: {
- type: Boolean,
- default: true
- },
- isH5: {
- type: Boolean,
- default: true
- },
- isGroup: {
- type: Boolean,
- default: false
- }
- });
- const emits = defineEmits(['sendMessage', 'onTyping']);
- const { placeholder, isH5, enableAt, enableDragUpload, isGroup, enableTyping } =
- toRefs(props);
- const inputContentEmpty = ref(true);
- const inputBlur = ref(true);
- const editor = useEditor({
- extensions: [
- Document,
- Paragraph,
- Text,
- Placeholder.configure({
- emptyEditorClass: 'is-editor-empty',
- placeholder: placeholder.value
- }),
- Mention.configure({
- HTMLAttributes: {
- class: 'mention'
- },
- suggestion: enableAt.value && (MessageInputAtSuggestion() as any)
- }),
- CustomImage.configure({
- inline: true,
- allowBase64: true,
- HTMLAttributes: {
- class: 'custom-image'
- }
- })
- ],
- autofocus: true,
- editable: true,
- injectCSS: false,
- // handle input edtor typing (only in C2C and enable typing)
- onUpdate({ editor, transaction }) {
- if (!enableTyping.value || isGroup.value) return;
- inputBlur.value = !editor.isFocused;
- if (transaction?.doc?.content?.size > 2) {
- inputContentEmpty.value = false;
- } else {
- inputContentEmpty.value = true;
- }
- emits('onTyping', inputContentEmpty.value, inputBlur.value);
- },
- onFocus() {
- if (isH5.value && document?.getElementById('app')?.style) {
- // set app height when keyboard popup
- const keyboardHeight = document.body.scrollHeight - window.innerHeight;
- (
- document.getElementById('app') as any
- ).style.marginBottom = `${keyboardHeight}Px`;
- (
- document.getElementById('app') as any
- ).style.height = `calc(100% - ${keyboardHeight}Px)`;
- }
- if (!enableTyping.value || isGroup.value) return;
- inputBlur.value = true;
- emits('onTyping', inputContentEmpty.value, inputBlur.value);
- },
- onBlur() {
- if (isH5.value && document?.getElementById('app')?.style) {
- // reset app height to normal
- (document.getElementById('app') as any).style.marginBottom = ``;
- (document.getElementById('app') as any).style.height = `100%`;
- }
- if (!enableTyping.value || isGroup.value) return;
- inputBlur.value = true;
- emits('onTyping', inputContentEmpty.value, inputBlur.value);
- }
- });
- const editorContainer = ref();
- const handleEnter = (e: any) => {
- if (isH5?.value) {
- return;
- }
- e?.preventDefault();
- e?.stopPropagation();
- if (e.keyCode === 13 && e.ctrlKey) {
- // ctrl + enter: warp
- editor?.value?.commands?.insertContent('<p></p>');
- } else if (e.keyCode === 13) {
- // enter only: send message
- emits('sendMessage');
- }
- };
- // fileMap 存储 fileURL 与 fileObject 的映射
- const fileMap = new Map<string, any>();
- const handleFileDropOrPaste = async (e: any, type: string) => {
- e.preventDefault();
- e.stopPropagation();
- if (isH5.value) {
- return;
- }
- if (!enableDragUpload?.value && type === 'drop') {
- return;
- }
- if (
- (type === 'drop' && e.dataTransfer) ||
- (type === 'paste' && e.clipboardData)
- ) {
- const files =
- type === 'drop' ? e?.dataTransfer?.files : e?.clipboardData?.files;
- for (let i = 0; i < files.length; i++) {
- const file = files[i];
- const isImage = file.type.startsWith('image/');
- const fileSrc = isImage
- ? URL.createObjectURL(file)
- : await drawFileCanvasToImageUrl(file);
- editor?.value?.commands?.insertContent({
- type: 'custom-image',
- attrs: {
- src: fileSrc,
- alt: file?.name,
- title: file?.name,
- class: isImage ? 'normal' : 'file'
- }
- });
- fileMap.set(fileSrc, file);
- if (i === files.length - 1) {
- setTimeout(() => {
- editor?.value?.commands?.focus('end');
- editor?.value?.commands?.scrollIntoView();
- }, 10);
- }
- }
- }
- };
- // create file icon image
- // 为了避免重复创建拥有相同icon图标的img dom,将之前已有类型进行记录
- // 记录格式为 map<icon type,img dom>
- const fileIconDomMap = new Map<string, HTMLImageElement>();
- const addImageProcess = (src: string, type: string) => {
- return new Promise((resolve, reject) => {
- if (fileIconDomMap.has(type)) {
- resolve(fileIconDomMap.get(type));
- } else {
- let img = new Image();
- img.crossOrigin = 'anonymous';
- img.onload = () => {
- fileIconDomMap.set(type, img);
- resolve(img);
- };
- img.onerror = reject;
- img.src = src;
- }
- });
- };
- // draw file tag canvas
- const drawFileCanvasToImageUrl = async (file: any) => {
- const { name, type } = file;
- const canvas = document.createElement('canvas');
- let width = 160;
- let height = 50;
- canvas.style.width = width + 'Px';
- canvas.style.height = height + 'Px';
- // 设置内存中的实际大小(缩放以考虑额外的像素密度)
- let scale = window.devicePixelRatio; // 在视网膜屏幕上更改为 1 以查看模糊
- canvas.width = Math.floor(width * scale);
- canvas.height = Math.floor(height * scale);
- const ctx = canvas.getContext('2d');
- if (!ctx) {
- return '';
- }
- // 标准化坐标系以使用 css 像素
- ctx.scale(scale, scale);
- // draw icon
- const { iconSrc, iconType } = handleFileIconForShow(type);
- const img = await addImageProcess(iconSrc, iconType);
- ctx?.drawImage(img as any, 10, 10, 30, 30);
- // draw font
- const nameForShow = handleNameForShow(name);
- ctx.fillText(nameForShow, 45, 22);
- // canvas to url
- const dataURL = canvas.toDataURL();
- return dataURL;
- };
- const handleFileIconForShow = (type: string) => {
- const urlBase = 'https://web.sdk.qcloud.com/component/TUIKit/assets/file-';
- const fileTypes = [
- 'image',
- 'pdf',
- 'text',
- 'ppt',
- 'presentation',
- 'sheet',
- 'zip',
- 'word',
- 'video',
- 'unknown'
- ];
- let url = '';
- let iconType = '';
- fileTypes.forEach((typeName: string) => {
- if (type.includes(typeName)) {
- url = urlBase + typeName + '.svg';
- iconType = typeName;
- }
- });
- return {
- iconSrc: url ? url : urlBase + 'unknown.svg',
- iconType: iconType ? iconType : 'unknown'
- };
- };
- // 获取字符串的实际占位长度(字母or符号:1,其他(主要是中文):)
- const handleNameForShow = (value: string): string => {
- if (!value) {
- return value;
- }
- let res = '';
- let length = 0;
- for (let i = 0; i < value?.length; i++) {
- if (length > 16) {
- res += '...';
- break;
- }
- res += value[i];
- if (/[a-z]|[0-9]|[,;.!@#-+/\\$%^*()<>?:"'{}~]/i.test(value[i])) {
- length += 1;
- } else {
- length += 2;
- }
- }
- return res;
- };
- const getEditorContent = () => {
- return handleEditorForMessage();
- };
- const handleEditorForMessage = () => {
- const editorJSON = editor?.value?.getJSON();
- const content: any[] = [];
- const handleEditorContent = (root: any) => {
- if (!root || !root.type) {
- return;
- } else if (
- root.type !== 'text' &&
- root.type !== 'custom-image' &&
- root.type !== 'mention'
- ) {
- if (root.type === 'paragraph') {
- handleEditorNode(root);
- }
- if (root.content && root.content.length) {
- root.content.forEach((item: any) => {
- handleEditorContent(item);
- });
- }
- return;
- } else {
- handleEditorNode(root);
- }
- };
- const handleEditorNode = (node: any) => {
- // handle enter
- if (node.type === 'paragraph') {
- if (
- content.length > 0 &&
- content[content.length - 1] &&
- content[content.length - 1]?.type === 'text'
- ) {
- content[content.length - 1].payload.text += '\n';
- }
- } else if (
- node.type === 'text' ||
- (node.type === 'custom-image' && node?.attrs?.class === 'emoji')
- ) {
- const text = node.type === 'text' ? node?.text : node?.attrs?.alt;
- if (
- content.length > 0 &&
- content[content.length - 1] &&
- content[content.length - 1]?.type === 'text'
- ) {
- content[content.length - 1].payload.text += text;
- } else {
- content.push({
- type: 'text',
- payload: { text: text }
- });
- }
- } else if (
- node.type === 'custom-image' &&
- node?.attrs?.class === 'normal'
- ) {
- content.push({
- type: 'image',
- payload: { file: fileMap?.get(node?.attrs?.src) }
- });
- } else if (node.type === 'custom-image' && node?.attrs?.class === 'file') {
- const file = fileMap?.get(node?.attrs?.src);
- content.push({
- type: file?.type?.includes('video') ? 'video' : 'file',
- payload: { file }
- });
- } else if (node.type === 'mention') {
- const text = '@' + node?.attrs?.label + ' ';
- if (
- content.length > 0 &&
- content[content.length - 1] &&
- content[content.length - 1]?.type === 'text'
- ) {
- content[content.length - 1].payload.text += text;
- } else {
- content.push({
- type: 'text',
- payload: { text: text }
- });
- }
- if (content[content.length - 1]?.payload?.atUserList) {
- content[content.length - 1]?.payload?.atUserList?.push(node?.attrs?.id);
- } else {
- content[content.length - 1].payload.atUserList = [node?.attrs?.id];
- }
- }
- };
- handleEditorContent(editorJSON);
- if (
- content.length > 0 &&
- content[content.length - 1] &&
- content[content.length - 1]?.type === 'text' &&
- content[content.length - 1]?.payload?.text?.endsWith('\n')
- ) {
- const text = content[content.length - 1].payload.text;
- content[content.length - 1].payload.text = text?.substring(
- 0,
- text.lastIndexOf('\n')
- );
- }
- return content;
- };
- const addEmoji = (emoji: any) => {
- editor?.value?.commands?.insertContent({
- type: 'custom-image',
- attrs: {
- src: emoji?.url,
- alt: emoji?.name,
- title: emoji?.name,
- class: 'emoji'
- }
- });
- editor?.value?.commands.focus('end');
- editor?.value?.commands?.scrollIntoView();
- };
- const resetEditor = () => {
- editor?.value?.commands?.clearContent(true);
- fileMap?.clear();
- editor?.value?.commands?.focus('end');
- inputBlur.value = true;
- inputContentEmpty.value = true;
- };
- const setEditorContent = (content: any) => {
- editor?.value?.commands?.insertContent(content);
- };
- defineExpose({
- getEditorContent,
- addEmoji,
- resetEditor,
- setEditorContent
- });
- </script>
- <style scoped lang="scss">
- @import url('../../../styles/common.scss');
- @import url('../../../styles/icon.scss');
- .message-input {
- &-container {
- display: flex;
- flex-direction: column;
- flex: 1;
- height: calc(100% - 13Px);
- width: calc(100% - 20Px);
- padding: 3Px 10Px 10Px 10Px;
- overflow: hidden;
- }
- &-area {
- flex: 1;
- display: flex;
- overflow-y: scroll;
- &::-webkit-scrollbar {
- background: transparent;
- }
- }
- &-mute {
- flex: 1;
- display: flex;
- color: #999999;
- font-size: 14Px;
- justify-content: center;
- align-items: center;
- }
- }
- .message-input-container-h5 {
- flex: 1;
- height: auto;
- background: #f4f5f9;
- border-radius: 9.4Px;
- padding: 7Px 0Px 7Px 10Px;
- font-size: 16Px !important;
- max-height: 86Px;
- margin-right: 7Px;
- }
- </style>
- <style lang="scss">
- .ProseMirror {
- min-height: 100%;
- height: fit-content;
- flex: 1;
- font-size: 14Px;
- word-wrap: break-word;
- word-break: break-all;
- white-space: pre-wrap;
- div,
- ul,
- ol,
- dl,
- dt,
- dd,
- li,
- dl,
- h1,
- h2,
- h3,
- h4,
- p {
- margin: 0;
- padding: 0;
- font-style: normal;
- }
- p {
- * {
- vertical-align: bottom;
- }
- }
- -webkit-user-select: text;
- user-select: text;
- &-focused {
- border: none;
- outline: none;
- }
- img {
- &.ProseMirror-selectednode {
- outline: 2Px solid #68cef8;
- }
- }
- .custom-image {
- &-normal {
- max-height: 120Px;
- max-width: 200Px;
- }
- &-file {
- height: 50Px;
- width: 160Px;
- border: 1Px solid #e8e8e9;
- border-radius: 5Px;
- }
- &-emoji {
- height: 20Px;
- width: 20Px;
- }
- }
- .ProseMirror-selectednode {
- outline: 2Px solid #68cef8;
- cursor: none;
- }
- p,
- [contenteditable] {
- -webkit-user-select: text;
- user-select: text;
- }
- // placeholder style
- p.is-editor-empty:first-child::before {
- color: #adb5bd;
- content: attr(data-placeholder);
- float: left;
- height: 0;
- pointer-events: none;
- }
- }
- </style>
|