我在写一个网页版navicat,其中的一个小插曲。只是一种思路,肯定会有不完善的地方,我最近正在完善。目前词法分析只针对Mysql。
用jquery是因为方便,后期会去掉。除了jquery以外没有任何第三方插件。有个codemirror插件,没有细细地研究过。因为后期想封装成vue组件,所以codemirror这种感觉有点老了。
原理:h5 div有contenteditable属性,为true时div可以编辑。如果只有一个div开启编辑模式,同时触发事件去处理输入内容,并回显到这个div上,光标会自动回到0的位置。如果这个div内部有很多span标签,那么光标实际作用的对象是这些span,而不是这个div本身。所以要将光标强行改为原来位置,需要进行计算,而且退格和删除还要另外判断,所以很麻烦。这个想法暂时告一段落。
换一种思路,如果让这个用来编辑的div只用来响应编辑的动作,再用一个div专门用来渲染高亮后的代码标签,这样就简单多了。可以把显示用的div放在下层,编辑的div放在上层(字体颜色设为透明)。编辑会触发input事件,经过计算后将标签渲染到显示div上面(内容不变,只是样式不一样),造成一种实时高亮的假象。
运行效果:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>语法自动高亮编辑器</title>
<link rel="stylesheet" href="index.css">
<!-- 可以通过引入不同的css来切换主题-->
<link rel="stylesheet" href="theme.css">
<script src="../lib/jquery.js"></script>
<script src="codeParser.js"></script>
<script src="render.js"></script>
</head>
<body>
<!-- 外部容器-->
<div id="container">
<!-- 这个是真实接受输入的div,覆盖在显示div上方-->
<div class="innerDiv innerDiv-edit" contenteditable="true" id="editorDiv"></div>
<!--这个div只用来显示渲染出的结果-->
<div class="innerDiv innerDiv-display" id="displayDiv"></div>
</div>
</body>
</html>
index.css
/**
清除浏览器默认margin和padding
*/
* {
margin: 0;
padding: 0;
}
/**
外部容器的样式
*/
#container {
width: 800px;
height: 400px;
margin: 30px auto;
position: relative;
border: 2px solid red;
font-size: 25px;
border-radius: 5px;
}
/**
编辑和显示div的宽高都随父div
*/
.innerDiv {
width: 100%;
height: 100%;
position: absolute;/*绝对定位*/
}
/**
显示div的样式可以自定义
*/
.innerDiv-display {
}
/**
编辑div的样式
*/
.innerDiv-edit {
color: transparent;/*编辑字体颜色设为透明不可见*/
position: absolute;/*绝对定位*/
caret-color:black;/*光标颜色设为默认黑色*/
z-index: 999;/*提高层级使编辑div永远覆盖在最上方*/
outline: none;/*去掉默认编辑元素的outline*/
}
theme.css
这个分离出来方便扩展不同主题。
/*高亮主题颜色,可以自定义某个类型的样式*/
/**
单词
*/
.word {
color: black;
}
/**
空白符
*/
.blank {
}
/**
字符串
*/
.str {
color: green;
}
/**
整型
*/
.int {
color: blue;
}
/**
系统关键字
*/
.keyword {
color: red;
}
/**
注释
*/
.comment {
color: pink;
}
codeParser.js
/**
* 根据当前片段第一个字符判断类型
* @param char 需要判断的字符
* @returns {string} token类型
*/
function getType(char) {
if(/\s/.test(char)) {
return 'blank';
}else if(char === '\'') {
return 'singleStr';
}else if(char === '\"') {
return 'doubleStr';
}else if(/\d/.test(char)) {
return 'int';
}else if(char === '#') {
return 'comment';
}
}
/**
* 读取空白符
* @param str 原字符串
*/
function readBlankChar(str) {
endIndex = -1;
for (let i = index+1; i < str.length; i++) {
char = str.charAt(i);
//读到非空白符时停止
if(/\S/.test(char)) {
endIndex = i;
break
}
}
//防止读到内容结尾还没结束的情况
if(endIndex === -1) endIndex = str.length;
words.push({content:str.substring(index,endIndex),type:'blank'});
index = endIndex;
}
/**
* 读取单词
* @param str 原字符串
*/
function readWord(str) {
endIndex = -1;
for (let i = index+1; i < str.length; i++) {
char = str.charAt(i);
//读到空白符时停止
if(/\s/.test(char)) {
endIndex = i;
break;
}
}
//防止读到内容结尾还没结束的情况
if(endIndex === -1) endIndex = str.length;
let content = str.substring(index,endIndex);
//如果单词在关键字列表中,标记类型为关键字
if(keywords.includes(content)) {
words.push({content: content,type:'keyword'});
}else {
//默认类型为普通单词
words.push({content: content,type:'word'});
}
index = endIndex;
}
/**
* 读取单词
* @param str 原字符串
*/
function readComment(str) {
endIndex = -1;
for (let i = index+1; i < str.length; i++) {
char = str.charAt(i);
//读到换行时停止
if(char === '\n') {
endIndex = i;
break;
}
}
//防止读到内容结尾还没结束的情况
if(endIndex === -1) endIndex = str.length;
let content = str.substring(index,endIndex);
words.push({content: content,type:'comment'});
index = endIndex;
}
/**
* 读取字符串
* @param str 原字符串
* @param stopStr 结束符 可以是单引号或者双引号字符
*/
function readStr(str,stopStr) {
endIndex = -1;
for (let i = index+1; i < str.length; i++) {
char = str.charAt(i);
//读到结束符时停止
if(char === stopStr) {
endIndex = i;
break;
}
}
//防止读到内容结尾还没结束的情况
if(endIndex === -1) endIndex = str.length;
//MYSQL不区分单双引号,所以都记为str
words.push({content:str.substring(index,endIndex+1),type:'str'});
index = endIndex+1;
}
/**
* 读取整型
* @param str 原字符串
*/
function readInt(str) {
endIndex = -1;
for (let i = index+1; i < str.length; i++) {
char = str.charAt(i);
if(!/\d/.test(char)) {
endIndex = i;
break;
}
}
//防止读到内容结尾还没结束的情况
if(endIndex === -1) endIndex = str.length;
words.push({content:str.substring(index,endIndex),type:'int'});
index = endIndex;
}
/**
* 原始文本转为token对象数组
* @param str
*/
function toWords(str) {
while(index < str.length) {
char = str.charAt(index);
let type = getType(char);
//根据不同的类型去调用不同的处理函数
switch (type) {
case 'blank':
readBlankChar(str);
break;
case 'singleStr':
readStr(str,'\'');
break;
case 'doubleStr':
readStr(str,'\"');
break;
case 'int':
readInt(str);
break;
case 'comment':
readComment(str);
break;
default:
readWord(str);
}
}
}
/**
* token对象数组转为node str
* @returns {string}
*/
function toNodes() {
let nodeStr = '';
//遍历token数组根据不同的类型添加对应的类名
words.forEach(item => {
nodeStr += `<span class="${item.type}">${item.content}</span>`;
});
return nodeStr;
}
let index = 0;//当前指针
let endIndex;//结束指针
let char;//当前字符
let words = [];//token对象数组
const keywords = ['select','as','where','from'];//关键字数组
render.js
$(function () {
/**
* div 输入事件
*/
$('#editorDiv').on('input',function (e) {
index = 0;//索引回到0的位置
words = [];//单词表清空
let content = $(this).text();//获取原始内容
toWords(content);//解析单词表
let nodeStr = toNodes();//获取将要被渲染的元素
$('#displayDiv').html(nodeStr);//渲染到元素中
});
});