富文本编辑器或者text是没有办法插入一个自定义的html节点,所以最后决定用div的contenteditable属性,使其变成可编辑的状态,控制光标的位置来插入。
<template>
<div class="textarea-wrap">
<div
ref="textareaRef"
class="textarea"
:contenteditable="true"
:placeholder="props.placeholder"
style="height: 53px"
@paste="optimizePasteEvent"
@keypress="onKeypress"
@compositionend="onCompositionend"
@keydown="onKeydown"
@input="onChange"
id="editableDiv"
/>
<a-popover
v-model="visible"
placement="bottom"
:trigger="['click']"
overlayClassName="selectVariable"
>
<a class="ant-dropdown-link" @click="e => e.preventDefault()"> 选择变量</a>
<a-menu slot="content" class="selectVariableList">
<a-menu-item v-for="item in props.list" :key="item.value" @click="selectVariableList(item)">
{{ item.label }}
</a-menu-item>
</a-menu>
</a-popover>
<span class="showCount" v-if="showCount"> {{ textLength }} /{{ maxLength }}</span>
</div>
</template>
<script setup>
import { nextTick } from 'vue';
// import { extractText } from '../utils/constants';
import { ref, onBeforeMount, onMounted, watch, watchEffect, defineProps } from 'vue';
// 定义 props
const props = defineProps({
maxLength: {
type: Number,
},
placeholder: {
type: String,
default: '请输入内容',
},
value: {
type: String,
default: '',
},
showCount: {
type: Boolean,
default: false,
},
list: {
type: Array,
default: () => {
return [
{
value: '',
label: '暂无无数据',
},
];
},
},
});
const textareaRef = ref();
// 定义 emit
const emit = defineEmits(['update:innerHTML']);
// 定义 text 并初始化为 props.value
const text = ref(props.value);
const visible = ref(false);
const textLength = ref(0);
let hasBeenCalled = ref(false);
//输入非中文事件
const onKeypress = event => {
const diff = event.target.innerText.length - props.maxLength;
if (diff >= 0) {
event.stopPropagation();
event.preventDefault();
}
getTextLength();
};
const getTextLength = () => {
nextTick(() => {
textLength.value = textareaRef.value ? textareaRef.value.innerText.length : 0;
console.log('[update:value]', textareaRef.value.innerHTML);
emit('update:innerHTML', textareaRef.value.innerHTML);
});
};
//插入变量
const selectVariableList = item => {
const { value, label } = item;
const cusTextareaRef = textareaRef.value;
cusTextareaRef.focus();
nextTick(() => {
getTextLength();
if (textLength.value >= props.maxLength) return; //超过最大值,不执行了
// 创建新的 span 元素
const strongElement = document.createElement('strong');
let variable = `@{${label}}`;
let len = variable.length - (textLength.value + variable.length - props.maxLength);
if (len < variable.length) {
//如果超出了最大长度,则截取超出的部分
variable = variable.slice(0, len);
}
strongElement.textContent = variable;
strongElement.classList.add('variable');
strongElement.setAttribute('data-code', value);
strongElement.setAttribute('contenteditable', false);
if (cusTextareaRef) {
// 将 span 元素插入到编辑区域的末尾
cusTextareaRef.appendChild(strongElement);
// 将光标移动到插入内容的后面
const range = document.createRange();
range.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
} else {
console.error('Editable div not found');
}
getTextLength(); //更新已输入字符的数量
visible.value = false;
});
};
const replaceAll = (str, search, replacement) => {
return str.split(search).join(replacement);
};
//粘贴
const optimizePasteEvent = event => {
// 监听粘贴内容到输入框事件,对内容进行处理 处理掉复制的样式标签,只拿取文本部分
event && event.stopPropagation();
event && event.preventDefault();
let text = '';
event = event.originalEvent || event;
if (event.clipboardData && event.clipboardData.getData) {
text = event.clipboardData.getData('text/plain');
} else if (window.clipboardData && window.clipboardData.getData) {
text = window.clipboardData.getData('text');
}
text = replaceAll(text, '\r\n', ' ');
text = replaceAll(text, '\n', ' ');
text = replaceAll(text, '\r', ' ');
getTextLength(); //更新已输入字符的数量
let len = text.length - (textLength.value + text.length - props.maxLength);
if (len < text.length) {
//如果超出了最大长度,则截取超出的部分
text = text.slice(0, len);
}
window.document.execCommand('insertText', false, text);
getTextLength(); //更新已输入字符的数量
};
const onChange = event => {
const editableDiv = event.target;
const innerHTML = editableDiv.innerHTML;
// 检查是否只包含一个 <br> 标签
if (innerHTML === '<br>' || innerHTML === '<br/>') {
editableDiv.innerHTML = '';
}
getTextLength();
};
const onKeydown = event => {
console.log('[键盘按下onKeydown]', event);
if (event.keyCode === 13) {
event.preventDefault(); // 阻止浏览器默认换行操作
}
};
//输入中文时,触发的事件
const onCompositionend = event => {
const diff = event.target.innerText.length - props.maxLength;
getTextLength();
console.log('[diff]', diff);
if (diff > 0) {
const range = document.createRange();
const sel = window.getSelection();
const offset = sel.anchorOffset;
const node = sel.anchorNode;
const text = node.textContent;
range.selectNodeContents(node);
sel.removeAllRanges();
setTimeout(() => {
sel.addRange(range);
sel.extend(node, offset);
document.execCommand('delete', false);
document.execCommand('insertText', false, text.substring(0, offset - diff));
getTextLength();
}, 0);
}
};
const getHtml = () => {
return textareaRef.value.innerHTML;
};
const onEditorReady = newValue => {
nextTick(() => {
textareaRef.value.innerHTML = newValue;
getTextLength(); //更新已输入字符的数量
});
};
// 监听 props.value 的变化并更新 text
watch(
() => props.value,
newValue => {
if (newValue && !hasBeenCalled.value) {
hasBeenCalled.value = true;
onEditorReady(newValue);
}
},
{ immediate: true }, // 立即执行一次
);
defineExpose({
getHtml,
});
</script>
<style scoped lang="scss">
.textarea-wrap {
width: 100%;
position: relative;
height: 80px;
border-radius: 4px;
border: 1px solid #d9d9d9;
overflow-y: scroll;
// margin-bottom: 10px;
.textarea {
overflow: auto;
padding-left: 12px;
padding-top: 5px;
width: 100%;
height: 53px;
border: none;
border-radius: 2px;
color: #323233;
font-size: 14px;
font-weight: 400;
line-height: 18px;
&:empty::before {
font-size: 14px;
font-family: PingFangSC, PingFangSC-Regular;
font-weight: 400;
color: #bfbfbf;
content: attr(placeholder);
}
&:focus-visible {
outline: none;
}
}
::v-deep .ant-input {
resize: none;
}
.showCount {
position: absolute;
bottom: 0;
right: 10px;
display: inline-block;
color: #999;
height: 36px;
}
.ant-dropdown-link {
cursor: pointer;
position: absolute;
left: 10px;
bottom: 0;
height: 36px;
color: #3058ee;
}
::v-deep .ql-container {
// border-radius: 4px;
// border: 1px solid #d9d9d9;
border: none;
}
::v-deep .ql-blank {
&::before {
font-style: normal;
left: 11px;
color: #bfbfbf;
line-height: 20px;
font-size: 14px;
}
}
::v-deep.ql-editor {
padding: 11px;
box-sizing: border-box;
}
}
::v-deep .ql-container.ql-snow {
p,
span {
font-size: 14px;
font-weight: 400;
}
}
::v-deep .variable {
color: #3058ee;
cursor: pointer;
margin: 0 1px;
font-weight: 400;
}
</style>
<style>
.selectVariable {
.ant-popover-inner-content {
padding: 0 !important;
}
.selectVariableList {
width: 160px;
max-height: 240px;
background: #ffffff;
box-shadow: 0 2px 12px 0 #3337461a;
border-radius: 4px;
box-sizing: border-box;
overflow-y: scroll;
color: #333746;
}
.ant-menu-item-active {
background: #f7f8fa;
}
.ant-menu-item {
width: 100%;
}
::-webkit-scrollbar {
/*隐藏滚轮*/
display: none;
}
}
</style>