文本编辑的相关知识工作中最近做了一个关于文本编辑的相关需求。踩了一些坑,特此记录。需求阐述:这里就是一个传统的输入框。不过要计算你触发参数框的位置。也就是说记录你光标的位置。参数框出现之后,要支持一些键盘交互。这里利用原生事件绑定一下就可以了。选中相关的参数将其应用到输入框中。
需求描述

  1. 利用contenteditable元素来模拟输入框。利用这个来模拟有优点也有缺点,优点是div可以自适应高度。缺点是复制HTML的内容可以直接复制进去,并且当你利用js控制输入的内容。也就是说你当手动控制输入的内容时,光标会跳到开头。
    第一个问题html的内容可以直接复制进去。contenteditable的属性值有很多。
1
2
3
4
5
6
contenteditable="" 
contenteditable="events"
contenteditable="caret"
contenteditable="plaintext-only"
contenteditable="true"
contenteditable="false"

其中的plaintext-only就是让元素只能键入纯文本。true是也可以输入html元素。其它的属性我并没有去弄清楚,感兴趣的可以自己查阅相关的资料。
contenteditable元素的placeholder可以利用css来控制(edit是元素的classname), 在元素上利用pleaceholder属性来写上你要的placeholder的内容。

1
2
3
4
5
6
7
.edit:empty::before {
content: attr(placeholder);
}

.edit:focus:before {
content: none;
}

让元素变得可编辑其实也可以利用css来控制,user-modify。这里可以参考张鑫旭写的文章:https://www.zhangxinxu.com/wordpress/2016/01/contenteditable-plaintext-only
2. 记录光标的位置。
通过分析需求知道这里的参数框应该是脱离文档流。那么利用绝对定位或者固定定位都可以, 我用的绝对定位,所以我们需要计算光标的位置。
在js中,万物皆对象。由此可知。光标当然也是。于是我在MDN查了一下。发现我们Selection对象。MDN中相关知识点
它表示用户选择的文本范围或光标当前位置,也就是我们可以文字变蓝色的那个区域。而光标对象就在Selection中。可以通过getRangeAt方法获取.MDN中相关知识点.并且还可以通过Document对象构造Range(这个之后会用到).
有了rang对象,通过getBoundingClientRect方法可以得到与该对象相关的css边框集合(也就是DOMRect对象)。DOMRect 对象包含了一组用于描述边框的只读属性——left、top、right和bottom,单位为像素。除了 width 和 height 外的属性都是相对于视口的左上角位置而言的。

1
2
3
4
5
6
7
.edit:empty::before {
content: attr(placeholder);
}

.edit:focus:before {
content: none;
}

然后将left和top的值赋给参数框就可以了。
注: 这节的相关知识常用的场景是富文本编辑框。但这里面的坑也是数不胜数。感兴趣的可以看看这篇文章(https://imys.net/20161125/cursor-offset-at-input.html)
3. 每次手动改变输入框的内容时,光标位置总会变到最前面。
需求描述
当参数框出现时我需要一些键盘交互。这里我没有利用框架的UI,自己手动注册的事件。还挺简单的。
这个面板的html代码:

1
2
3
4
5
6
7
8
9
10
11
12
<el-card ref="paramPanel" v-show="paramPanel.cascaderPanelIsShow" class="cascader-panel"
v-bind:style="paramPanel.styleObject">
<div v-if="params.length" ref="cascaderContent" class="cascader-content">
<div class="cascader-item"
v-for="(param,index) in params" :key="index"
:ref="'paramMenu'+index"
:class="index == currentIndex?'isActive':''" @click="() => getCurrentParam(param.label)">
{{param.label}}
</div>
</div>
<div class="no-data-panel" v-else>NO DATA</div>
</el-card>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (e.code === 'ArrowUp') {
e.preventDefault(); // 取消事件的默认行为
if (this.currentIndex === 0) {
this.currentIndex = this.params.length - 1;
} else {
this.currentIndex--;
}
this.scrollParamPanelBar() // 蓝色的选中框移动时每次都要判断是否移动滚动条。
} else if (e.code === 'ArrowDown') {
e.preventDefault();
if (this.currentIndex === this.params.length - 1) {
this.currentIndex = 0;
} else {
this.currentIndex++;
}
this.scrollParamPanelBar() // 蓝色的选中框移动时每次都要判断是否移动滚动条。
} else if (e.code === 'Escape') {
this.makeParamPanelDisappear()
} else if (e.code === 'Enter') {
e.preventDefault();
if (this.params[this.currentIndex] && this.params[this.currentIndex].label) {
// 这里将当前的参数应用到输入框的内容中.
this.makeParamPanelDisappear()
}

判断是否滚动条的方法: scrollParamPanelBar

1
2
3
4
5
6
7
8
let offsetTop = this.$refs['paramMenu' + (this.currentIndex)][0].offsetTop // 当前选中元素的顶部距离panel的顶部的高度
// 这里判断可以滚动
if (offsetTop + this.$refs['paramMenu' + (this.currentIndex)][0].clientHeight > this.$refs['paramPanel'].$el.clientHeight) {
this.$refs['paramPanel'].$el.scrollTop = offsetTop + 当前选中的元素的高度(clientHeight) - this.$refs['paramPanel'].$el.clientHeight;
// 这里判断是否到底部。
} else if (this.$refs.cascaderContent.scrollHeight == this.$refs.cascaderContent.scrollTop + this.$refs.cascaderContent.clientHeight) {
this.$refs['paramPanel'].$el.scrollTop = 0;
}

因为参数被应用到输入框中,也就是说手动改了输入框的内容。那么光标对象会被重置到内容的最前面。产生这样的现象的原因是因为当你用js改变输入框的内容时并没有去创建光标对象,所以在这里就用到了上面的知识点利用Document对象构造Range,并手动控制光标的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let selection = document.getSelection();
// 创建新的Range对象
let range = document.createRange()
// 创建输入框的文本内容,这里仅仅是textNode对象
let newValue = document.createTextNode(editValue))
range.selectNodeContents(newValue)
// 移动光标的位置
range.setStart(newValue, index)
// 光标开始和光标结束重叠
range.collapse(true)
// 清除选定对象的所有光标对象
selection.removeAllRanges()
// 插入新的光标对象
selection.addRange(range)

这里其实主要运用的就是range的相关知识点MDN中相关知识点,你的需求可能不同但只要明白range相关api.调用就可以了。