在Vue 3项目中,我们经常需要创建自定义的UI组件,以满足特定的业务需求。本篇博客将介绍一个基于Vue 3的自定义多行文本输入组件,该组件具有灵活的配置选项和一些特定的功能,使其适用于各种场景。
组件结构
首先,让我们来看一下这个自定义组件的基本结构:
<template>
<div
class="input-count-container"
:class="`${disabled ? 'is-disabled' : ''}`"
:style="`height: ${height}; width: ${width}`">
<div
ref="displayContentRef"
class="content-wrapper"
v-html="displayContent"
:contenteditable="!disabled"
@blur="onDisplayContentBlur"
@input="onDisplayContentInput"></div>
</div>
</template>
<script setup lang="ts">
// 省略了引入部分
const emits = defineEmits(['update:modelValue', 'addressInputs']);
const props = defineProps({
modelValue: {
default: ''
},
disabled: {
type: Boolean,
default: false
},
height: {
type: String,
default: '100px'
},
upperCase: {
type: Boolean,
default: false
},
width: {
type: String,
default: '15vw'
}
});
const displayContentRef = ref();
const displayContent = ref('');
// 省略了其他变量和方法的声明
</script>
<style scoped lang="scss">
// 省略了样式部分
</style>
主要特性
- 支持自动高度调整,可以设置固定的高度或百分比高度。
- 可以禁用输入,添加
.is-disabled
类来显示禁用状态。 - 可以转换输入为大写字母形式。
- 支持设置组件的宽度。
组件功能
失焦事件处理
const onDisplayContentBlur = (event) => {
const $displayContent = displayContentRef.value;
let updateContent = $displayContent.innerText.replace(/\n\n/gi, '\n');
if (props.upperCase) {
updateContent = updateContent.toUpperCase();
}
emits('update:modelValue', updateContent);
};
在失焦事件处理中,我们对文本进行了一些处理,去除了多余的换行符,并且根据配置将文本转换为大写形式。
输入事件处理
const onDisplayContentInput = (ev) => {
const $displayContent = displayContentRef.value;
$displayContent.childNodes.forEach((node) => {
node.setAttribute('data-len', node.innerText.replace(/\n/gi, '').length);
});
};
输入事件处理函数用于实时统计每行字符数,通过 data-len
属性保存在对应的 div
元素上。
复制和粘贴事件处理
onMounted(() => {
const $displayContent = displayContentRef.value as HTMLDivElement;
$displayContent.oncopy = (e) => {
e.preventDefault();
const copyText = document.getSelection()?.toString() || '';
if (e.clipboardData) {
return e.clipboardData.setData('text/plain', copyText);
}
};
$displayContent.onpaste = (e) => {
e.preventDefault();
const $displayContent = displayContentRef.value as HTMLDivElement;
const selection = document.getSelection()!;
const contents: Array<string> = [];
let pasteContent = e.clipboardData?.getData('text') || '';
pasteContent = pasteContent.replace(/\r\n/gi, '\n');
// 处理粘贴内容,将其插入到光标位置
// ...(省略了处理逻辑)
};
});
通过在 onMounted
钩子中设置复制和粘贴事件的处理逻辑,实现了对粘贴内容的自定义处理,将其插入到光标位置。
样式设计
<style scoped lang="scss">
.input-count-container::-webkit-scrollbar { width: 0 !important }
.input-count-container {
// ...(省略了其他样式)
&.is-disabled {
// ...(省略了禁用状态样式)
}
}
</style>
完整代码
<template>
<div
class="input-count-container"
:class="`${disabled ? 'is-disabled' : ''}`"
:style="`height: ${height};width:${width}`">
<div
ref="displayContentRef"
class="content-wrapper"
v-html="displayContent"
:contenteditable="!disabled"
@blur="onDisplayContentBlur"
@input="onDisplayContentInput"></div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue';
const emits = defineEmits(['update:modelValue','addressInputs']);
const props = defineProps({
modelValue: {
default: ''
},
disabled: {
type: Boolean,
default: false
},
height: {
type: String,
default: '100px'
},
upperCase: {
type: Boolean,
default: false
},
width:{
type: String,
default: '15vw'
}
});
const displayContentRef = ref();
const displayContent = ref('');
const onDisplayContentBlur = (event) => {
const $displayContent = displayContentRef.value;
let updateContent = $displayContent.innerText.replace(/\n\n/gi, '\n');
if (props.upperCase) {
updateContent = updateContent.toUpperCase();
}
emits('update:modelValue', updateContent);
};
const onDisplayContentInput = (ev) => {
const $displayContent = displayContentRef.value;
$displayContent.childNodes.forEach((node) => {
node.setAttribute('data-len', node.innerText.replace(/\n/gi, '').length);
});
};
const setDisplayContent = () => {
displayContent.value = (props.modelValue || '')
.split('\n')
.map((lineContent) => {
return `<div class="line-content" data-len="${lineContent.length}">${lineContent}</div>`;
})
.join('');
};
onMounted(() => {
const $displayContent = displayContentRef.value as HTMLDivElement;
$displayContent.oncopy = (e) => {
e.preventDefault();
const copyText = document.getSelection()?.toString() || '';
if (e.clipboardData) {
return e.clipboardData.setData('text/plain', copyText);
}
};
$displayContent.onpaste = (e) => {
e.preventDefault();
const $displayContent = displayContentRef.value as HTMLDivElement;
const selection = document.getSelection()!;
const contents: Array<string> = [];
let pasteContent = e.clipboardData?.getData('text') || '';
pasteContent = pasteContent.replace(/\r\n/gi, '\n');
if (selection.isCollapsed) {
$displayContent.childNodes.forEach((node: HTMLDivElement) => {
if (node == selection.anchorNode || node == selection.anchorNode?.parentNode) {
contents.push(
node.innerText.slice(0, Math.min(selection.anchorOffset, selection.focusOffset)) +
pasteContent +
node.innerText.slice(Math.max(selection.anchorOffset, selection.focusOffset))
);
} else {
contents.push(node.innerText);
}
});
} else {
let direction: boolean | undefined = undefined;
let begin = false;
let startNode: any = undefined;
let endNode: any = undefined;
$displayContent.childNodes.forEach((node: HTMLDivElement) => {
if (direction == undefined) {
if (node == (selection.anchorNode?.parentElement as HTMLDivElement)) {
direction = true;
begin = true;
startNode = { node: selection.anchorNode, offset: selection.anchorOffset };
endNode = { node: selection.focusNode, offset: selection.focusOffset };
}
if (node == (selection.focusNode?.parentElement as HTMLDivElement)) {
direction = false;
begin = true;
startNode = { node: selection.focusNode, offset: selection.focusOffset };
endNode = { node: selection.anchorNode, offset: selection.anchorOffset };
}
if (begin) {
contents.push(node.innerText.slice(0, startNode.offset) + pasteContent);
}
}
if (begin) {
if (node == (endNode.node.parentElement as HTMLDivElement)) {
begin = false;
if (endNode.node.length != endNode.offset) {
contents.push(node.innerText.slice(endNode.offset) + '\n');
}
}
} else {
contents.push(node.innerText + '\n');
}
});
}
emits('update:modelValue', contents.join(''));
emits('addressInputs', contents.join(''));
};
});
watch(
[() => props.modelValue],
() => {
setDisplayContent();
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
.input-count-container::-webkit-scrollbar { width: 0 !important }
.input-count-container {
position: relative;
width: 100%;
height: 100px;
overflow-y: auto;
box-sizing: border-box;
color: var(--el-input-text-color, var(--el-text-color-regular));
background-color: var(--el-input-bg-color, var(--el-fill-color-blank));
background-image: none;
box-shadow: 0 0 0 1px var(--el-input-border-color, var(--el-border-color)) inset;
border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
transition: var(--el-transition-box-shadow);
margin-bottom: 2px;
:deep(.content-wrapper) {
resize: vertical;
padding: 5px 11px 0 11px;
line-height: 1.5;
width: 100%;
min-height: 100%;
.line-content {
position: relative;
min-height: 18px;
margin: 0 -10px;
padding: 0 18px 0 10px;
&::after {
content: attr(data-len);
text-align: center;
width: 18px;
height: 100%;
position: absolute;
top: 0;
right: 0;
margin-top: -5px;
padding-top: 5px;
background-color: rgba($color: #000000, $alpha: 0.2);
color: #333333;
}
&:nth-of-type(even) {
background-color: #eaeaea;
}
}
.line-content + .line-content {
&::after {
margin-top: 0;
padding-top: 0;
}
}
}
&.is-disabled {
background-color: var(--el-disabled-bg-color);
border-color: var(--el-disabled-border-color);
color: var(--el-disabled-text-color);
cursor: not-allowed;
}
}
</style>
使用组件
<InputCount
:disabled="disabled"
v-model="form.notifyLine"
:upper-case="true"
:width="width"></InputCount>