标题党身份暴露,本文主要讲了web中如何实现一个轻量的编辑器,纯纯个人探索过程,如有大神欢迎探讨,轻喷,文末有效果演示。
为什么要做?
本人就职于一家金融公司,IT小组就是为金融研究员们做一些工作提效的工具(话说回来做web的谁不是呢,工具人)。研究员们过去的日常工作都在 Excel上完成,繁琐低效,我们的任务就是把这件事搬到web上,从而也就有了“公式计算”的需求。
后端的Java兄弟支持了一套公式引擎,可以将用户输入的合法公式文本解析出来,去数据库提取数据运算后返回给前端。例如一些四则运算:
idx(10009) - idx(10010)
idx(10009) * 0.01
idx(10009) / 100
idx(10009) ^ 2
(idx(10009) - idx(10010)) /100
这表示寻找ID为xxxx的数据,进行运算,还有一些进阶的运算公式:
grow(idx(10009)) // 表示获取指定指标序列的环比增长率序列,即本期除以上期再减1
yoy(idx(10009)) // 获取指标序列的同比增长率序列,即本期除以去年同期再减1
前端之前就是提供一个textarea供用户输入,也不对用户输入内容做出校验,实际使用中用户输入很长很长的公式时,他自己都不确定是否完全符合语法故而无法计算,使用体验极差。
和领导的想法不谋而合:
这要是像咱们程序员写代码有个智能提示就好了
开工!
1. 很明显是个富文本编辑器啊
是的,要支持用户输入文本,还要智能着色,前端来看就是需要把用户输入内容变成一段内联style样式的HTML文本。 考虑到扩展性,此处未考虑采用任何开源富文本编辑器,那么手撸一个富文本编辑器需要什么呢?
- 接受用户输入,HTML内天然想到TextArea,天然支持输入文本,文本长了还能换行。
- Textarea无法处理丰富的内联样式,内联样式必然是一段HTML才好。
根据以上两点,此时一个想法迅速产生,一个div和一个textarea做成完全重叠,让textarea接受用户输入,通过一系列转换后变成HTML段落,通过innerHTML方法放在div中表现丰富的样式。此时需要注意两点:消除文字叠加的丑陋效果,textarea和div的文字摞在一起显示太反人类了,textarea的CSS添加一条color: transparent;
直接隐形,解决。由于样式导致的光标不对齐,div中由于有了丰富的样式,文字可能变胖变歪,总之和textarea的裸文本会逐渐不对齐,textarea中的光标可能看起来插在了某个字上...此处需要一个字体方面的考虑font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
设为等宽字体,这样每个字的横向空间都是相同的,后续实现出来确实没有出现光标奇怪的问题。
实现过程发现,textarea并不理想,因为一些内置默认样式(可能还有别的原因记不清了...),与div无法很好地对齐,最后采用了contenteditable div
来接受用户输入。
为了使元素可编辑,你所要做的就是在html标签上设置 "contenteditable" 属性,它几乎支持所有的HTML元素。下面是一个简单的示例,创建一个 "contenteditable" 属性为 "true" 的div元素,用户就可以编辑其内容了。—— 引用自MDN Web Doc
<div contenteditable="true">
This text can be edited by the user.
</div>
下文就将可编辑的div成为editdiv
,负责展示样式的称为cssdiv
。
2. 把两个div内容变化关联起来
本文包含一些vue3代码,不过核心逻辑与框架无关。
首先在editdiv
中监听input事件,拿到内容后一番处理使其包含样式,使用innerHTML赋值给cssdiv
。cssdiv
不需要做内容变化监听,完全被动接受内容即可。
3. 变漂亮
所谓的“一番处理”究竟是怎么处理呢?回头去看看公式的内容是不是很像程序语言的函数啊,函数名表示功能,括号接受参数,那么如果像IDE一样对文本做出代码高亮的效果,如果用户输入内容不符合语法规则,那么着色异常,一看就有问题。这个自己写太复杂了得不偿失,Google了一下代码高亮,除了highlight.js
(下文简称hljs
)基本没有别的答案,许多在线IDE都用它来实现代码高亮。
基于词法分析,hljs
可以做到根据不同编程语言实现准确的代码着色,并给出一段包含样式的HTML文本,本文直接选择了JavaScript风格(其实大部分语言都行)。
// 监听editdiv的input事件
function formulaChange(e: InputEvent) {
const el = formulaCodeRef.value!; // 这是cssdiv
const text = (e.target as HTMLDivElement)!.innerText; // 拿到editdiv文本
el.innerHTML = text; //先直接给到cssdiv
hljs.highlightElement(el); // hljs对cssdiv进行DOM劫持,处理其内容后自动渲染好彩色的文本
// 到这一步漂亮的文本已经出现了
setSuggestions(e); // 实现代码智能提示功能
nextTick(() => {
//实时给出当前光标位置
emit('updateEndOffset', window.getSelection()!.getRangeAt(0).endOffset);
});
}
此处引入另外一个知识点,HTML5的冷门DOM:Pre
。
pre元素表示预定义格式文本。在该元素中的文本通常按照原文件中的编排,以等宽字体的形式展现出来,文本中的空白符(比如空格和换行符)都会显示出来。(紧跟在
<pre>
开始标签后的换行符也会被省略) —— 引用自MDN Web Doc
简单讲就是pre标签适合表现一段有格式的文本:
<pre>
Text in a pre element
is displayed in a fixed-width
font, and it preserves
both spaces and
line breaks
</pre>
渲染出来就是(当然不会自带黑色背景)
Text in a pre element is displayed in a fixed-width font, and it preserves both spaces and line breaks
因此cssdiv也改用了pre标签,这也是hljs
的要求,所以核心DOM代码如下:
<div class="relative overflow-visible">
<div
class="formula-editor focus:outline-blue-600 rounded-md"
ref="formulaEditorRef"
contenteditable
@input="formulaChange"
@blur="updateFormula"
></div>
<!--你能看到的的文本就是pre里的,language-javascript告诉hljs使用javascript-->
<pre class="language-javascript code rounded-sm" ref="formulaCodeRef"></pre>
</div>
CSS:
// 这就是那个editdiv
.formula-editor {
// 编辑器背景黑色,光标搞了个接近白色
caret-color: #b8b8b8;
position: absolute;
overflow: hidden;
// 支持竖向变长变短
resize: vertical;
width: 100%;
height: 7.5rem;
z-index: 9;
padding: 4px 11px;
font-size: 16px;
line-height: 24px;
color: transparent;
white-space: wrap;
word-wrap: break-word;
word-break: break-all;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
}
// 这就是pre标签
.code {
padding: 4px 11px;
position: absolute;
//连续的空白符会被保留。在遇到换行符或者<br>元素,才会换行
white-space: pre-wrap;
//强制分割换行
word-wrap: break-word;
// 对于non-CJK (CJK 指中文/日文/韩文) 文本,可在任意字符间断行
word-break: break-all;
font-size: 16px;
line-height: 24px;
top: 0;
text-align: left;
width: 100%;
// pre只负责漂亮,不要响应任何鼠标事件,很重要
pointer-events: none;
}
总之就是通过一些字体大小,行高,换行规则来使两个容器的内容保持相同格式,这样光标就不会出现在奇怪的位置,pre标签内容天然等宽,editdiv
记得务必做成等宽字体。使用z-index: 9;
来让editdiv
在顶层接受鼠标键盘事件。pointer-events: none;
让pre标签安安静静的做个美男子。
4. 猜猜想要的公式
重头戏智能提示来了。首先这里想到的就是预备一个数组,包含了所有的内部公式例如grow
,sum``idx
这些,拿到用户输入文本,去匹配数组,生成一个建议列表,用户使用Tab按键选择,回车确定。
这个建议列表当然是要挂一个DOM上去了:
// 获取自动补全单词列表DOM
function setSuggestionsDOM(e: Ref<HTMLDivElement | undefined>) {
suggestionsDOM.value = e.value!;
suggestionsDOMDisplay = getComputedStyle(e.value!).getPropertyValue('display');
suggestionsDOM.value.style.display = 'none';
}
此处在节点渲染时就挂载上去,但通过display:none;
先隐藏,同时保留下原来的display值,可能是flex
,可能是block
等等,之后要还原给DOM。
建议列表的实现效果是拿到字符‘s’或‘su’,就给出建议‘sum’和‘sum_acc’等正则匹配到的文本,但此处注意,用户选了智能提示的单词后,也会触发input事件,故而需要一个autoComplate(Boolean)来区分一下是用户手打字还是程序自动补全。在input事件中,根据当前光标位置向前倒数,直到匹配到非英文字符,差不多这就是一个待自动补全的残缺单词了。
那么问题来了,或许你也一早就想问,怎么让那个建议列表跟随光标呢,这里我也想到找一找浏览器的API,硬是没有...(如果你知道可以写在评论区),所及生生根据光标位置和字宽、DOM宽高等信息,计算出一个合理的位置,当然这都要得益于使用了等宽字体。此处当然是在每次触发input事件时计算。
function setSuggestions(e: InputEvent) {
// 建议列表的DOM
const sugDomStlye = suggestionsDOM.value!.style;
// 识别为自动补全模式则return(不然无限循环了)
if (autoComplate.value) {
sugDomStlye.display = 'none';
return;
}
// 获得当前光标位置
endOffset = window.getSelection()!.getRangeAt(0).endOffset;
// 拿到文本
const text = (e.target as HTMLDivElement)!.innerText;
// 括号和非英文字符不管,return
if (['(', '[', '{'].includes(text.charAt(endOffset)) || /[^a-zA-Z]/i.test(e.data!)) {
sugDomStlye.display = 'none';
return;
}
// 获得待匹配的子字符串
let str = '';
startOffset = endOffset - 1;
// 从光标位置往前倒数,匹配到字符串开头或非字母字符
while (startOffset !== -1 && /[a-zA-Z]/i.test(text.charAt(startOffset))) {
str = `${text.charAt(startOffset)}${str}`;
startOffset -= 1;
}
// 此时就拿到了一个用户手敲的字符串,没有就return
startOffset += 1;
if (str.length === 0) {
sugDomStlye.display = 'none';
return;
}
// 去内置公式列表里匹配,简单粗暴使用字符串的include方法
fnList.value = allFnToken.filter((s) => s.includes(str.trim()));
if (fnList.value.length > 0) {
//这一段就是计算光标相对于屏幕的位置
const el = e.target as HTMLDivElement;
const { x, y, width } = el.getBoundingClientRect();
const singleLineLen = parseInt(width / 8.8);
const left = (endOffset % singleLineLen) * 8.8 + 14 + x;
const top = (parseInt(endOffset / singleLineLen) + 1) * 24 + y;
Object.assign(sugDomStlye, {
left: `${left}px`,
top: `${top}px`,
display: suggestionsDOMDisplay,
});
show.value = true;
window.addEventListener('keypress', keyboardListener);
} else {
sugDomStlye.display = 'none';
show.value = false;
window.removeEventListener('keypress', keyboardListener);
}
}
当建议列表出现时,开始监听键盘事件,keyboardListener
中实现了用户按下tab切换建议列表中的单词,按下回车执行自动补全。关于tab切换,这里需要了解一些关于浏览器键盘切换焦点的知识,重点是HTML的tabindex
属性。
tabindex指示其元素是否可以聚焦,以及它是否/在何处参与顺序键盘导航(通常使用
Tab
键,因此得名)。它接受一个整数作为值,具有不同的结果,具体取决于整数的值 —— 引用自MDN Web Doc
- tabindex=负值 (通常是tabindex=“-1”),表示元素是可聚焦的,但是不能通过键盘导航来访问到该元素,用JS做页面小组件内部键盘导航的时候非常有用。
tabindex="0"
,表示元素是可聚焦的,并且可以通过键盘导航来聚焦到该元素,它的相对顺序是当前处于的DOM结构来决定的。- tabindex=正值,表示元素是可聚焦的,并且可以通过键盘导航来访问到该元素;它的相对顺序按照tabindex 的数值递增而滞后获焦。如果多个元素拥有相同的 tabindex,它们的相对顺序按照他们在当前DOM中的先后顺序决定。
根据键盘序列导航的顺序,值为 0
、非法值、或者没有 tabindex 值的元素应该放置在 tabindex 值为正值的元素后面。
所以建议列表的关键也是加一个tabindex属性:
<span
v-for="item in fnList"
:key="item"
:data-suggestion="item"
tabIndex="0"
class="pl-1 focus:bg-primary focus:text-white leading-4 text-gray-500"
>{{ item }}
</span>
这样你就可以按tab让当前聚焦元素切换到这些建议列表了。
async function keyboardListener(e: KeyboardEvent) {
// 获得当前聚焦元素,也就是用户按tab选的单词
const activeElement = document.activeElement as HTMLElement;
const str = activeElement.dataset.suggestion;
// 响应一下回车键,注意要拦截默认事件,不然你将会插入一个回车换行符。
if (str && e.key === 'Enter') {
const dom = inputDOM.value!;
const text = dom.innerText;
const replaceText = fnMap[str].value;
const str1 = text.substring(0, startOffset);
const str2 = text.substring(endOffset);
dom.innerText = `${str1}${replaceText}${str2}`;
// 触发input原生事件
const evt = document.createEvent('HTMLEvents');
evt.initEvent('input', true, true);
autoComplate.value = true;
dom.dispatchEvent(evt);
dom.focus();
const focusDOM = window.getSelection()!.getRangeAt(0)!;
// 设置光标位置
focusDOM.setStart(dom.childNodes[0], startOffset + fnMap[str].endOffset);
focusDOM.collapse(true);
// 阻止输入回车字符
e.preventDefault();
autoComplate.value = false;
}
}
这里需要用createEvent
和dispatchEvent
去模拟一个input事件,把自动补全的文本添加到DOM中,你可能会想到直接innerText好了,不行,别忘了前面我们是监听input事件,才把用户输入文本同步到pre标签中,所以此处也要触发input事件。最后需要把光标位置设置在合理的位置,此处补全单词也会自动加个括号,光标应是出现括号中间更为便捷。
浏览器中并非是直接给出“光标”的概念,而是“选择对象”,也就是你用鼠标在文本段落中可以拉选一段文本,右键复制等等那个操作,focusDOM.setStart
表示设置这个选择对象的起点,focusDOM.collapse(true)
表示起点终点重合,看起来也就是修改了光标的位置。
以上,基本就实现了一个支持自动补全内置语法的编辑器,个人认为实现光标附近挂一个DOM的方式仍然过于笨拙,如有更好思路欢迎讨论。效果演示: