perf: 优化聊天输入框

This commit is contained in:
kuaifan 2022-04-15 09:22:52 +08:00
parent 8c05d8791d
commit 522ca02b36
8 changed files with 243 additions and 463 deletions

View File

@ -57,6 +57,8 @@
"node-sass": "^6.0.1",
"notification-koro1": "^1.1.1",
"postcss": "^8.4.5",
"quill": "^1.3.7",
"quill-mention": "^3.1.0",
"resolve-url-loader": "^4.0.0",
"sass": "^1.45.1",
"sass-loader": "^12.4.0",

View File

@ -0,0 +1,219 @@
<template>
<div class="chat-input-wrapper">
<div ref="editor"></div>
</div>
</template>
<style lang="scss">
.chat-input-wrapper {
display: inline-block;
width: 100%;
.ql-editor {
padding: 4px 7px;
font-size: 14px;
max-height: 100px;
&.ql-blank {
&::before {
left: 7px;
right: 7px;
color: #ccc;
font-style: normal;
}
}
}
}
</style>
<script>
import Quill from 'quill';
import "quill/dist/quill.snow.css";
import "quill-mention";
import "quill-mention/dist/quill.mention.min.css";
export default {
name: 'ChatInput',
props: {
value: {
type: [String, Number],
default: ''
},
placeholder: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
enterSend: {
type: Boolean,
default: true
},
options: {
type: Object,
required: false,
default: () => ({})
},
maxlength: {
type: Number
},
},
data() {
return {
quill: null,
_content: '',
_options: {},
};
},
mounted() {
this.init();
},
beforeDestroy() {
this.quill = null
delete this.quill
},
watch: {
// Watch content change
value(newVal) {
if (this.quill) {
if (newVal && newVal !== this._content) {
this._content = newVal
this.quill.pasteHTML(newVal)
} else if(!newVal) {
this.quill.setText('')
}
}
},
// Watch disabled change
disabled(newVal) {
if (this.quill) {
this.quill.enable(!newVal)
}
}
},
methods: {
init() {
const atValues = [
{ id: 1, value: "Fredrik Sundqvist" },
{ id: 2, value: "Patrik Sjölin" }
];
const hashValues = [
{ id: 3, value: "Fredrik Sundqvist 2" },
{ id: 4, value: "Patrik Sjölin 2" }
];
// Options
this._options = Object.assign({
theme: null,
readOnly: false,
placeholder: this.placeholder,
modules: {
keyboard: {
bindings: {
'short enter': {
key: 13,
shortKey: true,
handler: _ => {
if (!this.enterSend) {
this.$emit('on-send', this.quill)
return false;
}
return true;
}
},
'enter': {
key: 13,
shiftKey: false,
handler: _ => {
if (this.enterSend) {
this.$emit('on-send', this.quill)
return false;
}
return true;
}
}
}
},
mention: {
mentionDenotationChars: ["@", "#"],
source: function(searchTerm, renderList, mentionChar) {
let values;
if (mentionChar === "@") {
values = atValues;
} else {
values = hashValues;
}
if (searchTerm.length === 0) {
renderList(values, searchTerm);
} else {
const matches = [];
for (let i = 0; i < values.length; i++)
if (
~values[i].value.toLowerCase().indexOf(searchTerm.toLowerCase())
)
matches.push(values[i]);
renderList(matches, searchTerm);
}
}
}
}
}, this.options)
// Instance
this.quill = new Quill(this.$refs.editor, this._options)
this.quill.enable(false)
// Set editor content
if (this.value) {
this.quill.pasteHTML(this.value)
}
// Disabled editor
if (!this.disabled) {
this.quill.enable(true)
}
// Mark model as touched if editor lost focus
this.quill.on('selection-change', range => {
if (!range) {
this.$emit('on-blur', this.quill)
} else {
this.$emit('on-focus', this.quill)
}
})
// Update model if text changes
this.quill.on('text-change', _ => {
if (this.maxlength > 0 && this.quill.getLength() > this.maxlength) {
this.quill.deleteText(this.maxlength, this.quill.getLength());
}
let html = this.$refs.editor.children[0].innerHTML
const quill = this.quill
const text = this.quill.getText()
if (/^(\<p\>\<br\>\<\/p\>)+$/.test(html)) html = ''
this._content = html
this.$emit('input', this._content)
this.$emit('on-change', { html, text, quill })
})
// Emit ready event
this.$emit('on-ready', this.quill)
},
focus() {
this.$nextTick(() => {
this.quill && this.quill.focus()
})
},
blur() {
this.$nextTick(() => {
this.quill && this.quill.blur()
})
}
}
}
</script>

View File

@ -1,396 +0,0 @@
<template>
<div :class="wrapClasses">
<template v-if="type !== 'textarea'">
<div :class="[prefixCls + '-group-prepend']" v-if="prepend" v-show="slotReady"><slot name="prepend"></slot></div>
<i class="ivu-icon" :class="['ivu-icon-ios-close-circle', prefixCls + '-icon', prefixCls + '-icon-clear' , prefixCls + '-icon-normal']" v-if="clearable && currentValue && !itemDisabled" @click="handleClear"></i>
<i class="ivu-icon" :class="['ivu-icon-' + icon, prefixCls + '-icon', prefixCls + '-icon-normal']" v-else-if="icon" @click="handleIconClick"></i>
<i class="ivu-icon ivu-icon-ios-search" :class="[prefixCls + '-icon', prefixCls + '-icon-normal', prefixCls + '-search-icon']" v-else-if="search && enterButton === false" @click="handleSearch"></i>
<span class="ivu-input-suffix" v-else-if="showSuffix"><slot name="suffix"><i class="ivu-icon" :class="['ivu-icon-' + suffix]" v-if="suffix"></i></slot></span>
<span class="ivu-input-word-count" v-else-if="showWordLimit">{{ textLength }}/{{ upperLimit }}</span>
<span class="ivu-input-suffix" v-else-if="password" @click="handleToggleShowPassword">
<i class="ivu-icon ivu-icon-ios-eye-off-outline" v-if="showPassword"></i>
<i class="ivu-icon ivu-icon-ios-eye-outline" v-else></i>
</span>
<transition name="fade">
<i class="ivu-icon ivu-icon-ios-loading ivu-load-loop" :class="[prefixCls + '-icon', prefixCls + '-icon-validate']" v-if="!icon"></i>
</transition>
<input
:id="elementId"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
ref="input"
:type="currentType"
:class="inputClasses"
:placeholder="placeholder"
:disabled="itemDisabled"
:maxlength="maxlength"
:readonly="readonly"
:name="name"
:value="currentValue"
:number="number"
:autofocus="autofocus"
@keyup.enter="handleEnter"
@keyup="handleKeyup"
@keypress="handleKeypress"
@keydown="handleKeydown"
@focus="handleFocus"
@blur="handleBlur"
@compositionstart="handleComposition"
@compositionupdate="handleComposition"
@compositionend="handleComposition"
@input="handleInput"
@change="handleChange"
@paste="handlePaste">
<div :class="[prefixCls + '-group-append']" v-if="append" v-show="slotReady"><slot name="append"></slot></div>
<div :class="[prefixCls + '-group-append', prefixCls + '-search']" v-else-if="search && enterButton" @click="handleSearch">
<i class="ivu-icon ivu-icon-ios-search" v-if="enterButton === true"></i>
<template v-else>{{ enterButton }}</template>
</div>
<span class="ivu-input-prefix" v-else-if="showPrefix"><slot name="prefix"><i class="ivu-icon" :class="['ivu-icon-' + prefix]" v-if="prefix"></i></slot></span>
</template>
<template v-else>
<textarea
:id="elementId"
:wrap="wrap"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
ref="textarea"
:class="textareaClasses"
:style="textareaStyles"
:placeholder="placeholder"
:disabled="itemDisabled"
:rows="rows"
:maxlength="maxlength"
:readonly="readonly"
:name="name"
:value="currentValue"
:autofocus="autofocus"
@keyup.enter="handleEnter"
@keyup="handleKeyup"
@keypress="handleKeypress"
@keydown="handleKeydown"
@focus="handleFocus"
@blur="handleBlur"
@compositionstart="handleComposition"
@compositionupdate="handleComposition"
@compositionend="handleComposition"
@input="handleInput"
@paste="handlePaste">
</textarea>
<span class="ivu-input-word-count" v-if="showWordLimit">{{ textLength }}/{{ upperLimit }}</span>
</template>
</div>
</template>
<script>
import { oneOf, findComponentUpward } from 'view-design-hi/src/utils/assist';
import calcTextareaHeight from 'view-design-hi/src/utils/calcTextareaHeight';
import Emitter from 'view-design-hi/src/mixins/emitter';
import mixinsForm from 'view-design-hi/src/mixins/form';
const prefixCls = 'ivu-input';
export default {
name: 'DragInput',
mixins: [ Emitter, mixinsForm ],
props: {
type: {
validator (value) {
return oneOf(value, ['text', 'textarea', 'password', 'url', 'email', 'date', 'number', 'tel']);
},
default: 'text'
},
value: {
type: [String, Number],
default: ''
},
size: {
validator (value) {
return oneOf(value, ['small', 'large', 'default']);
},
default () {
return !this.$IVIEW || this.$IVIEW.size === '' ? 'default' : this.$IVIEW.size;
}
},
placeholder: {
type: String,
default: ''
},
maxlength: {
type: [String, Number]
},
disabled: {
type: Boolean,
default: false
},
icon: String,
autosize: {
type: [Boolean, Object],
default: false
},
rows: {
type: Number,
default: 2
},
readonly: {
type: Boolean,
default: false
},
name: {
type: String
},
number: {
type: Boolean,
default: false
},
autofocus: {
type: Boolean,
default: false
},
spellcheck: {
type: Boolean,
default: false
},
autocomplete: {
type: String,
default: 'off'
},
clearable: {
type: Boolean,
default: false
},
elementId: {
type: String
},
wrap: {
validator (value) {
return oneOf(value, ['hard', 'soft']);
},
default: 'soft'
},
prefix: {
type: String,
default: ''
},
suffix: {
type: String,
default: ''
},
search: {
type: Boolean,
default: false
},
enterButton: {
type: [Boolean, String],
default: false
},
// 4.0.0
showWordLimit: {
type: Boolean,
default: false
},
// 4.0.0
password: {
type: Boolean,
default: false
}
},
data () {
return {
currentValue: this.value,
prefixCls: prefixCls,
slotReady: false,
textareaStyles: {},
isOnComposition: false,
showPassword: false
};
},
computed: {
currentType () {
let type = this.type;
if (type === 'password' && this.password && this.showPassword) type = 'text';
return type;
},
prepend () {
let state = false;
if (this.type !== 'textarea') state = this.$slots.prepend !== undefined;
return state;
},
append () {
let state = false;
if (this.type !== 'textarea') state = this.$slots.append !== undefined;
return state;
},
showPrefix () {
let state = false;
if (this.type !== 'textarea') state = this.prefix !== '' || this.$slots.prefix !== undefined;
return state;
},
showSuffix () {
let state = false;
if (this.type !== 'textarea') state = this.suffix !== '' || this.$slots.suffix !== undefined;
return state;
},
wrapClasses () {
return [
`${prefixCls}-wrapper`,
{
[`${prefixCls}-wrapper-${this.size}`]: !!this.size,
[`${prefixCls}-type-${this.type}`]: this.type,
[`${prefixCls}-group`]: this.prepend || this.append || (this.search && this.enterButton),
[`${prefixCls}-group-${this.size}`]: (this.prepend || this.append || (this.search && this.enterButton)) && !!this.size,
[`${prefixCls}-group-with-prepend`]: this.prepend,
[`${prefixCls}-group-with-append`]: this.append || (this.search && this.enterButton),
[`${prefixCls}-hide-icon`]: this.append, // #554
[`${prefixCls}-with-search`]: (this.search && this.enterButton)
}
];
},
inputClasses () {
return [
`${prefixCls}`,
{
[`${prefixCls}-${this.size}`]: !!this.size,
[`${prefixCls}-disabled`]: this.itemDisabled,
[`${prefixCls}-with-prefix`]: this.showPrefix,
[`${prefixCls}-with-suffix`]: this.showSuffix || (this.search && this.enterButton === false)
}
];
},
textareaClasses () {
return [
`${prefixCls}`,
{
[`${prefixCls}-disabled`]: this.itemDisabled
}
];
},
upperLimit () {
return this.maxlength;
},
textLength () {
if (typeof this.value === 'number') {
return String(this.value).length;
}
return (this.value || '').length;
}
},
methods: {
handleEnter (event) {
this.$emit('on-enter', event);
if (this.search) this.$emit('on-search', this.currentValue);
},
handleKeydown (event) {
this.$emit('on-keydown', event);
},
handleKeypress(event) {
this.$emit('on-keypress', event);
},
handleKeyup (event) {
this.$emit('on-keyup', event);
},
handleIconClick (event) {
this.$emit('on-click', event);
},
handleFocus (event) {
this.$emit('on-focus', event);
},
handleBlur (event) {
this.$emit('on-blur', event);
if (!findComponentUpward(this, ['DatePicker', 'TimePicker', 'Cascader', 'Search'])) {
this.dispatch('FormItem', 'on-form-blur', this.currentValue);
}
},
handleComposition(event) {
if (event.type === 'compositionstart') {
this.isOnComposition = true;
}
if (event.type === 'compositionend') {
this.isOnComposition = false;
this.handleInput(event);
}
},
handleInput (event) {
if (this.isOnComposition) return;
let value = event.target.value;
if (this.number && value !== '') value = Number.isNaN(Number(value)) ? value : Number(value);
this.$emit('input', value);
this.setCurrentValue(value);
this.$emit('on-change', event);
},
handleChange (event) {
this.$emit('on-input-change', event);
},
handlePaste (event) {
this.$emit('on-input-paste', event);
},
setCurrentValue (value) {
if (value === this.currentValue) return;
this.$nextTick(() => {
this.resizeTextarea();
});
this.currentValue = value;
if (!findComponentUpward(this, ['DatePicker', 'TimePicker', 'Cascader', 'Search'])) {
this.dispatch('FormItem', 'on-form-change', value);
}
},
resizeTextarea () {
const autosize = this.autosize;
if (!autosize || this.type !== 'textarea') {
return false;
}
const minRows = autosize.minRows;
const maxRows = autosize.maxRows;
this.textareaStyles = calcTextareaHeight(this.$refs.textarea, minRows, maxRows);
},
focus () {
if (this.type === 'textarea') {
this.$refs.textarea.focus();
} else {
this.$refs.input.focus();
}
},
blur () {
if (this.type === 'textarea') {
this.$refs.textarea.blur();
} else {
this.$refs.input.blur();
}
},
handleClear () {
const e = { target: { value: '' } };
this.$emit('input', '');
this.setCurrentValue('');
this.$emit('on-change', e);
this.$emit('on-clear');
},
handleSearch () {
if (this.itemDisabled) return false;
this.$refs.input.focus();
this.$emit('on-search', this.currentValue);
},
handleToggleShowPassword () {
if (this.itemDisabled) return false;
this.showPassword = !this.showPassword;
this.focus();
const len = this.currentValue.length;
setTimeout(() => {
this.$refs.input.setSelectionRange(len, len);
}, 0);
}
},
watch: {
value (val) {
this.setCurrentValue(val);
}
},
mounted () {
this.slotReady = true;
this.resizeTextarea();
}
};
</script>

View File

@ -83,19 +83,15 @@
<div :class="['dialog-footer', msgNew > 0 && dialogMsgList.length > 0 ? 'newmsg' : '']" @click="onActive">
<div class="dialog-newmsg" @click="onToBottom">{{$L('' + msgNew + '条新消息')}}</div>
<slot name="inputBefore"/>
<DragInput
<ChatInput
ref="input"
v-model="msgText"
class="dialog-input"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 3 }"
v-model="msgText"
:maxlength="20000"
@on-focus="onEventFocus"
@on-blur="onEventblur"
@on-keydown="chatKeydown"
@on-input-paste="pasteDrag"
:placeholder="$L('输入消息...')" />
@on-send="sendMsg"
:placeholder="$L('输入消息...')"/>
<div v-if="msgText != ''" class="dialog-send" @click="sendMsg">
<Icon type="md-send" />
</div>
@ -157,7 +153,6 @@
</template>
<script>
import DragInput from "../../../components/DragInput";
import ScrollerY from "../../../components/ScrollerY";
import {mapState} from "vuex";
import DialogView from "./DialogView";
@ -166,10 +161,11 @@ import {Store} from "le5le-store";
import UserInput from "../../../components/UserInput";
import DrawerOverlay from "../../../components/DrawerOverlay";
import DialogGroupInfo from "./DialogGroupInfo";
import ChatInput from "../../../components/ChatInput";
export default {
name: "DialogWrapper",
components: {DialogGroupInfo, DrawerOverlay, UserInput, DialogUpload, DialogView, ScrollerY, DragInput},
components: {ChatInput, DialogGroupInfo, DrawerOverlay, UserInput, DialogUpload, DialogView, ScrollerY},
props: {
dialogId: {
type: Number,
@ -382,17 +378,8 @@ export default {
}
},
chatKeydown(e) {
if (e.keyCode === 13) {
if (e.shiftKey) {
return;
}
e.preventDefault();
this.sendMsg();
}
},
pasteDrag(e, type) {
chatPasteDrag(e, type) {
this.dialogDrag = false;
const files = type === 'drag' ? e.dataTransfer.files : e.clipboardData.files;
const postFiles = Array.prototype.slice.call(files);
if (postFiles.length > 0) {
@ -401,11 +388,6 @@ export default {
}
},
chatPasteDrag(e, type) {
this.dialogDrag = false;
this.pasteDrag(e, type);
},
chatDragOver(show, e) {
let random = (this.__dialogDrag = $A.randomString(8));
if (!show) {

View File

@ -398,17 +398,13 @@
@dragleave.prevent="taskDragOver(false, $event)">
<div class="no-tip">{{$L('暂无消息')}}</div>
<div class="no-input">
<DragInput
<ChatInput
class="dialog-input"
v-model="msgText"
type="textarea"
:disabled="sendLoad > 0"
:rows="1"
:autosize="{ minRows: 1, maxRows: 3 }"
:maxlength="20000"
:placeholder="$L('输入消息...')"
@on-keydown="msgKeydown"
@on-input-paste="msgPasteDrag"/>
@on-send="msgDialog"/>
<div class="no-send" @click="msgDialog">
<Loading v-if="sendLoad > 0"/>
<template v-else>
@ -437,11 +433,11 @@ import DialogWrapper from "./DialogWrapper";
import ProjectLog from "./ProjectLog";
import {Store} from "le5le-store";
import TaskMenu from "./TaskMenu";
import DragInput from "../../../components/DragInput";
import ChatInput from "../../../components/ChatInput";
export default {
name: "TaskDetail",
components: {DragInput, TaskMenu, ProjectLog, DialogWrapper, TaskUpload, UserInput, TaskPriority, TEditor},
components: {ChatInput, TaskMenu, ProjectLog, DialogWrapper, TaskUpload, UserInput, TaskPriority, TEditor},
props: {
taskId: {
type: Number,
@ -1064,18 +1060,6 @@ export default {
}
},
msgKeydown(e) {
if (e.keyCode === 13) {
if (e.shiftKey) {
return;
}
e.preventDefault();
if (this.msgText) {
this.msgDialog();
}
}
},
msgDialog() {
if (this.sendLoad > 0) {
return;
@ -1136,7 +1120,8 @@ export default {
this.msgText = "";
},
msgPasteDrag(e, type) {
taskPasteDrag(e, type) {
this.dialogDrag = false;
const files = type === 'drag' ? e.dataTransfer.files : e.clipboardData.files;
this.msgFile = Array.prototype.slice.call(files);
if (this.msgFile.length > 0) {
@ -1145,11 +1130,6 @@ export default {
}
},
taskPasteDrag(e, type) {
this.dialogDrag = false;
this.msgPasteDrag(e, type);
},
taskDragOver(show, e) {
let random = (this.__dialogDrag = $A.randomString(8));
if (!show) {

View File

@ -142,6 +142,7 @@
line-height: 20px;
padding-top: 2px;
color: #aaaaaa;
white-space: normal;
&.pointer {
cursor: pointer;
@ -527,22 +528,12 @@
background-color: #F4F5F7;
padding: 10px 52px 10px 12px;
border-radius: 10px;
.ivu-input {
border: 0;
resize: none;
background-color: transparent;
&:focus {
box-shadow: none;
}
}
}
.dialog-send {
position: absolute;
top: 0;
right: 14px;
right: 28px;
bottom: 0;
font-size: 18px;
width: 46px;

View File

@ -106,6 +106,9 @@
.dialog-input {
width: calc(100% - 44px);
}
.dialog-send {
right: 22px;
}
}
}
}

View File

@ -384,10 +384,6 @@
.dialog-nav {
height: 54px;
justify-content: center;
.dialog-title {
flex: 0;
width: auto;
}
}
.dialog-footer {
position: relative;
@ -406,6 +402,9 @@
.dialog-input {
width: calc(100% - 44px);
}
.dialog-send {
right: 22px;
}
}
}
}