输入框彩色文字 - vue3模仿 elementui 输入框的简易富文本框

1,159 阅读7分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

1. 提出需求

产品又双叒叕提出了神奇需求,就是在进行文本框操作时候,文本框失焦时,判断文本是否存在某些特定词汇,如果存在,标记这些特定词汇为不同颜色,大概如下图。这一块的业务逻辑本身已经足够复杂了,现在加上这块需求简直雪上加霜(不是)。

image.png

2. 解决方案

由于这块表单一开始使用elementui做的,里面还加上了挺多复杂的业务逻辑的且耦合度超级高,如果要改造这一块的话需要保证写出一个Input组件,且输入输出和el-form-item基本一致,才能做到对整体最小的改动。如果只在input上改的话基本不可能,input里面没法加样式。后来想到了元素的一个属性:contenteditable 属性,这个属性可以将只读元素变为可编辑元素。

3. 理论存在,实践开始

3.1 可编辑元素--contenteditable

3.1.1 contenteditable

全局属性 contenteditable 是一个枚举属性,表示元素是否可被用户编辑。
如果可以,浏览器会修改元素的部件以允许编辑。

目前contenteditable属性属于非实验阶段的值有三个:true/false/plaintext-only

  1. true: 浏览器会修改元素的部件以允许编辑。
  2. false: 元素不是可编辑的。
  3. plaintext-only: 只允许编辑纯文本值(ctrl + shift + v)。 tips: 如果没给出该属性或设置了无效的属性值,则其默认值继承自父元素:即,如果父元素可编辑,该子元素也可编辑。 按照产品目前的需求,我这边需要使用plaintext-only这个值,不过看了一下感人的兼容性,我晒干了沉默,那就假设世界上没有火狐,先撸出来demo再说。

image.png

  1. 先给div加个contenteditable属性和一个自定义属性(placeholder)
<div class="input_wrapper">
	<span class="input_label">测试</span>
	<div contenteditable="plaintext-only" class="input_inner" data-text="请输入测试" 
        @focus="inputFocus"></div>
</div>
  1. 使用data-text模拟placeholder属性: 除去自定义属性外,还需要再样式那边加上一个伪类,才会出现placeholder
.input_inner:empty:before {
   content: attr(data-text);
   color: rgba(0, 0, 0, 0.08);
}
  1. 聚焦的时候,input外框变色
const inputFocus = (e) => {
	const tar = e.target || e.srcEvent;
	if (!tar.className.includes('is_focus')) {
		tar.className += ' is_focus';
	}
};
  1. 这样一个最最最基础的demo就写完了

3.1.2 光标乱飘

cv了好几个demo之后发现了一个挺严重的问题,👆上面的代码块应该可以看出来,先focus第一个输入框,然后点击第一个输入框和第二个输入框之间,还没有focus第二个输入框,光标已经再第二个输入框了。我真的谢
然后排查问题排查了好久以为差不多这个方案G的时候,发现原来是可编辑区域加上了display: inline-block导致的,我真的佛了,后续加上定位之后就不会乱飘了,👆上面的代码块可以试试。

3.2 标记输入框内文字不同颜色

接下来时核心功能:失焦后标记输入框内文字不同颜色。我这边采用的方案是遍历特定词汇数组和输入框内文本(联调之后是后端返回给我,我这边暂时先瞎写一下),如果文本内存在特定词汇,就替换掉可编辑区域的innerHTML来实现。

<!-- 父组件 -->
<CustomziInput
	v-model="someValue"
	:audit="['violence', 'fantasticy']"
	:sensitive="['pornography', 'forg']"
/>

父组件传的audit是显示红色特定词汇数组,sensitive是显示黄色特定词汇数组。

<!-- 子组件 -->
<div
	contenteditable="plaintext-only"
	class="input_inner"
	@blur="inputBlur"
	ref="inner"
></div>
<script lang="ts">
import { defineComponent, onMounted, ref, computed } from '@vue/composition-api';
export default defineComponent({
	props: {
                // 父组件v-model值
		value: {
			default: '',
			type: String,
		},
                // 父组件传入第一个特定词汇数组
		audit: {
			default: () => [],
			type: Array,
		},
                // 父组件传入第一个特定词汇数组
		sensitive: {
			default: () => [],
			type: Array,
		},
	},
        setup(){
            const inner = ref(null); // ref
            const inputBlur = (e) => {};
            return {
                inner,
            }
        }
});
</script>

这块逻辑主要是循环判断匹配所有的特定词汇,并加上样式(这里样式应该由父组件传入,我这边偷懒就暂时这么写了)

const inputBlur = (e) => {
    const tar = e.target || e.srcEvent;

    if (tar.className.includes('is_focus')) {
            tar.className = 'input_inner';
    }
    let text = tar.innerText;
    props.sensitive.map((item) => {
            text = text.replace(new RegExp(item, 'g'), `<span class='sensitive_text'>${item}</span>`);
    });
    props.audit.map((item) => {
            text = text.replace(new RegExp(item, 'g'), `<span class='audit_text'>${item}</span>`);
    });
    if ((text.includes('audit_text') && !tar.className.includes('is_audit'))) {
            tar.className += ' is_audit';
    } else if (text.includes('sensitive_text') && !tar.className.includes('is_sensitive')) {
            tar.className += ' is_sensitive';
    } else {
            tar.className = 'input_inner';
    }
    tar.innerHTML = text;
    emit('input', tar.innerText);
};
.input_wrapper {
	margin-bottom: 40px;
	height: 40px;
	width: 600px;
	position: relative;

	.input_label {
		width: 90px;
		margin-right: 12px;
		box-sizing: border-box;
		font-size: 14px;
		font-weight: 500;
		color: rgba(0, 0, 0, 0.85);
		float: left;
		&:before {
			content: '*';
			color: transparent;
			margin-right: 4px;
		}
	}
	.input_inner {
		-webkit-user-select: text;
		float: left;
		background-color: #fff;
		background-image: none;
		border-radius: 4px;
		border: 1px solid #dcdfe6;
		color: #606266;
		font-size: inherit;
		box-sizing: border-box;
		padding-left: 15px;
		padding-right: 50px;
		height: 40px;
		line-height: 40px;
		transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
		border-radius: 6px;
		width: calc(100% - 102px);
		outline: none;
		&:empty:before {
			content: attr(data-text);
			color: rgba(0, 0, 0, 0.08);
		}
	}

	.is_required {
		&:before {
			content: '*';
			color: #f56c6c;
			margin-right: 4px;
		}
	}

	.is_focus {
		border: 1px solid #1860ff;
	}

	.is_audit {
		border: 1px solid red;
	}

	.is_sensitive {
		border: 1px solid yellow;
	}
}
.sensitive_text {
	color: yellow;
}
.audit_text {
	color: red;
}

这里还有个需要注意的点是input框不能回车,但是加上contenteditable属性的元素是可以回车的,所以这里需要阻止键盘的默认事件

<div
	contenteditable="plaintext-only"
	class="input_inner"
	@blur="inputBlur"
	@keydown="enter"
	ref="inner"
></div>
const enter = (e) => {
	if (e.keyCode == 13) {
		e.preventDefault();
                // 如果回车加上失焦功能
                const tar = e.target || e.srcEvent;
		tar.blur();
	}
};

image.png 这样基本就能实现了,我认为比较困难的是不破坏原有代码结构和功能,需要将这部分功能封装成和el-form-item类似的组件。

3.3 模拟 elementui传参

现在加上input框和表单项的一些属性,因为项目中暂时只用到这些,所以暂时只加上了placeholder/isRequire/maxLength/rule这几个属性。

3.3.1 label&必填项&placeholder

<!-- 父组件 -->
<CustomziInput
	label="necessary"
	v-model="someValue"
	:isRequire="true"
	placeholder="请输入内容"
	:audit="['violence', 'fantasticy']"
	:sensitive="['pornography', 'forg']"
/>

如果是必填项,在组件前面加个*

<!-- 子组件 -->
<div class="input_wrapper">
	<span class="input_label" :class="isRequire && 'is_required'"
		>{{ label ? label + ':' : '' }}</span
	>
	<div
		contenteditable="plaintext-only"
		:data-text="placeholder"
		class="input_inner"
		@blur="inputBlur"
		@keydown="enter"
		ref="inner"
	></div>
</div>
	props: {
		placeholder: {
			default: '请输入内容',
		},
		label: {
			default: '',
		},
		isRequire: {
			default: false,
		},
		value: {
			default: '',
			type: String,
		},
		audit: {
			default: () => [],
			type: Array,
		},
		sensitive: {
			default: () => [],
			type: Array,
		},
	},

image.png

3.3.2 校验规则&提示语

模拟校验规则还是比较麻烦的,因为要保证入参和出参大致相同。先来模拟一下父组件的入参:

const inputRule = [
	{ required: true, message: 'Please enter required fields', trigger: 'blur' },
	{ min: 3, max: 10, message: '长度在 3 到 10 个字符', trigger: 'blur' },
];

因为校验规则需要对事件监听,可能一个事件需要对应多个操作,所以这里使用addEventListener(一个事件可以重复写且不会被覆盖),而且入参的规则触发条件有可能是数组:['blur','change']。 所以我这边在mounted钩子这里取循环规则数组,然后监听。 先在子组件这边加上一个校验未通过提示语。

<div class="tips_message">{{ tips }}</div>
const tips = ref<string>(''); // tips

在mounted时候我这里区分了触发条件tigger是数组还是字符串。同时,校验规则有两个默认规则:是否必填项和最大最小长度判断。 判断是字符串还是数组我这边用了Object.prototype.toString.call(),我感觉这里写的挺孬的,后续再看怎么改吧=_=

onMounted(() => {
	props.value && (inner.value.innerText = props.value);
		for (let i = 0; i < props.rule.length; i++) {
			let _item = props.rule[i] as any;
			if (Object.prototype.toString.call(_item.trigger) === '[object String]') {
				if (_item.required) {
					判断必填项(_item.trigger);
				}

				if (_item.min || _item.max) {
					判断最大长度(_item.trigger);
				}
			}

			if (Object.prototype.toString.call(_item.trigger) === '[object Array]') {
				_item.trigger.map((item) => {
				if (_item.required) {
					判断必填项(item);
				}

				if (_item.min || _item.max) {
					判断最大长度(item);
				}
			});
		}
	}
});

先来写对必填项的判断:如果输入框内的值trim之后不为空,清空必填项的提示语,否则提示语为传入的rule的message。

const requireFn = (trigger: string) => {
	inner.value.addEventListener(
		trigger,
		() => {
			if (!props.value.trim()) {
				tips.value = _item.message;
			} else {
				tips.value = '';
			}
		},
		true
	);
};

然后是对长度的判断:当输入框内存在值的时候(这里一定要加,不然的话提示语tips会被替换),如果当前输入框内值不满足规则长度需求,提示语为传入rule的message,否则清空。

const minMaxFn = (trigger: string) => {
	inner.value.addEventListener(
		trigger,
		() => {
			let vl = props.value.length;
			if (props.value.trim()) {
				if (vl < _item.min || vl > _item.max) {
					tips.value = _item.message;
				} else {
					tips.value = '';
				}
			}
		},
		true
	);
};

现在已经基本实现了: 输入框为空的时候 ->

image.png

不满足字数要求的时候 ->

image.png

3.3.3 长度限制

input框有个功能就是字数超过限制的时候就禁止输入了,所以需要再加上一个input的事件。注意!是input事件不能使用change事件是无效的。

<div v-if="maxLength" class="max_length">{{ value.length }}/{{ maxLength }}</div>
const inputChange = () => {
	if (props.maxLength && inner.value.innerText.length >= props.maxLength) {
		inner.value.innerText = inner.value.innerText.slice(0, props.maxLength);
	}
	emit('input', inner.value.innerText);
	inner.value.className = 'input_inner is_focus';
};

3.3.4 光标乱飘 x2

加上长度限制的时候,当字数超过限制的时候进行了slice操作,这个操作会导致光标移到首位,需要在这个操作之后,将光标移到文本最后。

const inputChange = () => {
	if (props.maxLength && inner.value.innerText.length >= props.maxLength) {
		inner.value.innerText = inner.value.innerText.slice(0, props.maxLength);
		if (window.getSelection) {
			let range = window.getSelection();
			range.selectAllChildren(inner.value); //range 选择obj下所有子内容
			range.collapseToEnd(); //光标移至最后
		}
	}
	emit('input', inner.value.innerText);
	inner.value.className = 'input_inner is_focus';
};

3.4 代码

代码放这里了 代码

4. 总结

 到这里基本写完了,但是还是存在很多问题没有考虑,比如说自定义的校验规则函数怎么写,提交表单时的异步校验等等等,还有关于contenteditable属性的兼容性问题,光标乱飘等,甚至想直接用封装好的富文本库,这些写出来的话后续补充。=_=