js 公式编辑器 - 自定义匹配规则 - 带提示下拉框 - 动态获取光标像素坐标

js 公式编辑器 - 自定义匹配规则 - 带提示下拉框 - 动态获取光标像素坐标

引言

  前段时间发了一个编辑器的插件,忙完后自己再次进行了详细的测试,然后心里冒出一句:“这谁写的这么奇葩的插件?完全没什么luan用啊!”

自己做了让自己不满意的事,咋整?男人不怕累,花了时间重写(为世界上所有像我一样勤劳的男人点赞)~

思维导图

  在小生看来,在开发每一个新功能的时候都应该做到心中有一张思维导图:功能实现逻辑和实现功能大致的方法。当然我们不可能在还没动手

前就考虑得面面俱到,但在正式开发之前心里对整个流程有个清晰的印象肯定会让我们在动手时愈加流畅(喝口娃哈哈美滋滋,看图~):

js 公式编辑器 - 自定义匹配规则 - 带提示下拉框 - 动态获取光标像素坐标

流程效果图

js 公式编辑器 - 自定义匹配规则 - 带提示下拉框 - 动态获取光标像素坐标

  触发检索事件字符可自定义,默认为 $,输入 $ 触发检索显示,此时检索值为空,所以显示所有选项,继续输入 a ,检索值为 a,显示匹配选项,当再输入 . 时, 检索值获取条件发生改变(具体我们等下看代码),

图四中为整个流程在控制台中的记录。

js代码 -- 监听输入框

 全局变量

 考虑到里面小方法比较多,为了简化代码,这里我选择模块化一下,需要用到以下全局变量。这里特别提一下:持续事件和点事件的区别,持续顾名思义,持续事件就是一直触发的事件,这里 $ 触发检索事件后,检索值 selectVal

 是变化的,但是我们又不需要它一直处于触发状态,怎么办呢?对,开关,我们可以给这个事件设置一个开关,条件满足时打开开关,事件持续触发,结束后关闭开关,结束检索事件,这里设置的开关是:searchStart;而点事件

 这里就是输入 . 时触发的事件,它只需要在输入 . 时获取相关的值就行了,不需要连续触发,这里我们设置参数 enterCharacter : 当前输入的字符

var _this = $(this);
var e = event || window.event; // 键值兼容
var searchStart = false; // 设置检索事件开关
var checkCharacter = false; // 输入字符检索开关  
var oldCurrentPos = ''; // 检索值开始的位置
var currentPos = ''; // 检索值结束的位置
var selectVal = ''; // 检索值
var pos = ''; // 设置光标位置
var enterCharacter = ''; // 当前输入的字符
var dotVal; // 输入 . 时从0到当前光标位置文本
var dotDollerPos; // 获取往后查找离 . 最近的 $ 的下标,引文输入 . 时的检索值即dotSelectVal不包含 $ 本身,所以需要加1 
var dotSelectVal; // 输入 . 时的检索值

  插入输入框 

  首先插入下拉框,当然留到后面插入也可以(你开心你说什么都是对的),但是这里有个点需要注意一下:为什么选择插入在body下?因为我们获取到的下拉框的位置是绝对定位坐标。

 // 插入下拉框
 _this.dropdown = $('<ul class="editTips" style="display:none;"></ul>');
// 获取到的弹出的下拉框的位置是绝对定位的坐标,所以得把弹出的层放到$("body").after(_this.dropdown);   
 _this.dropdown.css({
 'width':opts.dropdownWidth,
 'position':'absolute',
 });
 _this.css({
 'position': 'relative',
 });

 注意:这里我们提一下,要获取检索值,即 selectVal,我们需要知道事件触发时光标所在的位置,即 oldCurrentPos,以及光标当前位置 currentPos,有了这两个 下标,我们才能动态获取 selectVal

 获取光标当前位置

  关于获取输入框光标以及获取值等方法,不了解的朋友可以去看一下 range 方法,当然无数前辈已经做过无数归纳总结讲解(向前辈们敬礼~):

 // 获取当前光标位置 currentPos
  var getStart =function() {  
   var all_range = '';
   if (navigator.userAgent.indexOf("MSIE") > -1) { //IE
   if( _this.get(0).tagName == "TEXTAREA" ){ 
    // 根据body创建textRange
    all_range = document.body.createTextRange();
    // 让textRange范围包含元素里所有内容
    all_range.moveToElementText(_this.get(0));
   } else {
    // 根据当前输入元素类型创建textRange
    all_range = _this.get(0).createTextRange();
   }
   // 输入元素获取焦点
   _this.focus();
   // 获取当前的textRange,如果当前的textRange是一个具体位置而不是范围,textRange的范围从currentPos到end.此时currentPos等于end
   var cur_range = document.selection.createRange();
   // 将当前的textRange的end向前移"选中的文本.length"个单位.保证currentPos=end
   cur_range.moveEnd('character',-cur_range.text.length)
   // 将当前textRange的currentPos移动到之前创建的textRange的currentPos处, 此时当前textRange范围变为整个内容的currentPos处到当前范围end处
   cur_range.setEndPoint("StartToStart",all_range);

   // 此时当前textRange的Start到End的长度,就是光标的位置
   currentPos = cur_range.text.length;
   } else {
   // 文本框获取焦点
   _this.focus();
   // 获取当前元素光标位置
   currentPos = _this.get(0).selectionStart;
   //console.log("光标当前位置:"+currentPos);
   }
   // 返回光标位置
   return currentPos;
  };  

                 获取检索值开始位置

  检索开始位置,即事件触发时光标所在位置,直白来说,就是把事件触发时光标所在位置 currentPos 赋值给 oldCurrentPos 储存起来,然后与新的 currentPos 组

  成的区域 (oldCurrentPos,currentPos)就是我们检索值所在区域 

               

 // 获取检索值开始位置 oldCurrentPos
  var getOldCurrentPos = function(){
   getStart(); // 开始输入的时候的光标位置 currentPos
   oldCurrentPos = currentPos; // 储存输入开始位置
   console.log(oldCurrentPos);
  }

  设置光标位置

  选择当前项重组输入框 value 值后光标是默认显示在最后的,这当然不符合我们的开发需求,我们想要的效果是事件结束时光标能在我们编辑结束的位置(关于value值重组我们在下面的方法中再看)               

 // 设置光标位置
  var setCarePosition = function(start,end) {
   if(navigator.userAgent.indexOf("MSIE") > -1){
   var all_range = '';
   if( _this.get(0).tagName == "TEXTAREA" ){ 
    // 根据body创建textRange
    all_range = document.body.createTextRange();
    // 让textRange范围包含元素里所有内容
    all_range.moveToElementText(_this.get(0));
   } else {
    // 根据当前输入元素类型创建textRange
    all_range = _this.get(0).createTextRange();
   }
   _this.focus();
   // 将textRange的start设置为想要的start
   all_range.moveStart('character',start);
   // 将textRange的end设置为想要的end. 此时我们需要的textRange长度=end-start; 所以用总长度-(end-start)就是新end所在位置
   all_range.moveEnd('character',-(all_range.text.length-(end-start)));
   // 选中从start到end间的文本,若start=end,则光标定位到start处
   all_range.select();
   }else{
   // 文本框获取焦点
   _this.focus();
   // 选中从start到end间的文本,若start=end,则光标定位到start处
   _this.get(0).setSelectionRange(start,end);
   }
  };

  结束检索事件

  在结束检索事件中我们需要初始化下拉框以及关闭开关,这里需要将该方法声明在获取检索值方法前面,因为获取值后整个事件流程结束,我们需要初始化变量为下一次事件触发做好准备             

// 结束检索事件
  var endSearch = function(){
   _this.dropdown.find("li").remove(); // 移除下拉框中的选项
   _this.dropdown.hide(); // 隐藏下拉框
   searchStart = false; // 初始化检索开关 searchStart
   enterCharacter=''; // 初始化当前字符
  }

   获取检索的值

   看下方代码,我们能够获取值的前提是 searchStart 开关 打开状态,这里我们为了保持插件的灵活性,将触发字符设置为变量,这里默认为 $ 和 . ,enterCharacter为当前输入的字符,

        因为当我们输入 . 时,selectVal 的获取规则会改变,所以这里我们需要将 selectVal 获取方式区分开来,注意:这里我们要考虑到存在一个操作 -- 回删,输入 $,下拉框出来了,但是我

        们又觉得此处 $ 出现得还不是时候(反正就是要删),删除 $,那么检索事件也就结束,初始化相关变量。当输入的是 . 时,如果要替换值,那么我们需要的获取从 . 在的位置往后找

   到离 . 最近的 $ 符号,得到其在文本中的位置,这样我们才能重组 value            

 // 获取检索的值 selctVal
  var getSelectVal = function(){
   var val = _this.val();
   if( searchStart == true && enterCharacter != opts.levelCharacter ){ // 当输入的是字符 triggerCharacter 的时候 默认为 $
   selectVal = val.substring(oldCurrentPos,currentPos); // 检索值直接为获取的文本区域
   }
   if( searchStart == true && enterCharacter == opts.levelCharacter ){ // 当输入的是字符 levelCharacter 的时候 默认为 .
   dotVal = val.slice(0,currentPos);
   dotDollerPos = dotVal.lastIndexOf(opts.triggerCharacter)+1;
   dotSelectVal = dotVal.substring(dotDollerPos,currentPos);
   selectVal = dotSelectVal;
   console.log("到当前下标的字符串为:"+dotVal);
   console.log("到当前下标最近的$下标是:"+dotDollerPos);
   console.log("输入 . 时检索值为:"+dotSelectVal);
   }  
   console.log("获取的值区域为:"+oldCurrentPos+"-"+currentPos);
   if( oldCurrentPos > currentPos ){ // 回删时清除选项li 隐藏下拉框
   endSearch()
   }  
  }

  改变输入框 value 值,定位光标位置

  因为我们这里存在两种选择方式,鼠标点击和按 enter 键,两者的区别只在于执行事件的方式,将同样的代码写两遍未免有点不美,这里我们将它摘出来

  注意:此处需要区分触发检索事件的符号是 $ 还是 . ,因为符号不同,我们获取的值是不同的,光标定位也是不同            

 // 选中li当前项 改变输入框value值 定位光标
  var changeValue = function(){
   var val = _this.val(); 
   var liTxt = _this.dropdown.find(".active").text();
   var liTxtLength = liTxt.length;
   var valLength = val.length;
   // 此处需要区分触发检索事件的符号是
   if( enterCharacter == opts.levelCharacter ){ // 如果是 .
   var beforeSelectVal = val.substring(0,dotDollerPos);  
   }
   else{ // 如果是 &
   var beforeSelectVal = val.substring(0,oldCurrentPos);
   }
   var beforeSelectValLength = beforeSelectVal.length;
   var afterSelectVal = val.substring(currentPos,valLength);
   var pos = liTxtLength + beforeSelectValLength;
   val = beforeSelectVal+liTxt+afterSelectVal;
   _this.val(val);
   setCarePosition(pos,pos); // 将光标定位在插入值后面
   endSearch();
   console.log("文本长度:"+beforeSelectVal.length);
   console.log("li文本为:"+liTxt);
   console.log("前部为:"+beforeSelectVal);
   console.log("后部分为:"+afterSelectVal);
   return false; // 此处必须加上return false 不然会调用callbacktips 初始化 dropdown
  }

  定义回调函数

    获取检索值之后就需要发送请求了,我们拿到返回的数组 arr_json 后,将其遍历生成 li 添加到下拉框中              

 // 定义回调函数 callbacktips
  var callbacktips = function(arr_json){
   // 初始化 UL 
   _this.dropdown.find("li").remove();
   if( arr_json ){
   for( i=0;i<arr_json.length;i++ ){
    var n = arr_json[i].indexOf(selectVal);
    if( n != -1 ){
    _this.dropdown.append('<li>'+arr_json[i]+'</li>'); 
    }else{
    return;
    }     
   };
   }   
   _this.dropdown.show();
   _this.dropdown.find("li:first-child").addClass("active");
   // 自定义样式
   _this.dropdown.find("li").css({ 
   'width':'100%',
   });
  };

  获得焦点时获取光标位置

  这里我们直接调用上面的方法就行了       

  // 获得焦点的时候获取光标位置
  _this.click(function(){
   getOldCurrentPos()
  });

  阻止键盘默认事件

  这里我们需要判断下拉框的状态:显示还是隐藏        

 //下拉框显示时 阻止键盘方向键默认事件
  _this.keydown(function(e){
   var dropdownIsshow = _this.dropdown.css("display");
   if( dropdownIsshow == "block" ){
   if( e.keyCode == 38 || e.keyCode == 40 || e.keyCode == 13 ){
    e.preventDefault();
   }
   }
  })

  keyup 事件

  通过keyuo事件:”我们能实时监听输入框;也能通过按键切换当前项以及改变光标位置;也能限制输入字符范围,比如这里:当输入某些字符时,将会被认为输入了不合法字符而终止检索事件;

  我们的事件开关也是通过该事件能改变其状态的以及 enter 键选取当前项          

// 监听输入框value值变化
  _this.keyup(function(e){
   var val = _this.val(); 
   // 当前项索引
   var n = _this.dropdown.find(".active").index();
   // li 个数
   var n_max = _this.dropdown.find("li").length;  
   getStart(); // 获得最新光标位置
   // 方向键控制 li 选项
   if( e.keyCode == 38 ){   
   if(n-1>=0){
    _this.dropdown.find('li').eq(n-1).addClass("active").siblings().removeClass("active");
   }
   if( n == 0){
    _this.dropdown.find('li').eq(n_max-1).addClass("active").siblings().removeClass("active");
   }
   return false; // 此处必须加上return false 不然会重复初始化
   }  
   if( e.keyCode == 40 ){
   if(n<n_max-1){
    _this.dropdown.find('li').eq(n+1).addClass("active").siblings().removeClass("active"); 
   }
   if( n+1 == n_max ){
    _this.dropdown.find('li').eq(0).addClass("active").siblings().removeClass("active");
   }
   return false; // 此处必须加上return false 不然会重复初始化
   } 
   if( e.keyCode != 37 && e.keyCode != 38 && e.keyCode != 39 && e.keyCode != 40 ){
   var reg = new RegExp("[`~!@#^&*()=|{}':;',\\[\\]<>/?~!@#¥……&*()——|{}【】‘;:”“'。,、?]");
   enterCharacter = val.substring(currentPos-1,currentPos); // 当前输入的字符
   //console.log(enterCharacter);
   if( reg.test(enterCharacter) == false && enterCharacter != " "){ // 输入的字符合法 可以执行检索事件
    //console.log("输入字符合法");
    checkCharacter = true;
   }else{
    checkCharacter = false;
    endSearch()
    console.log("输入了不合法字符");
    //console.log(selectVal);   
   }   
   }   
   console.log("当前输入的字符是:"+enterCharacter);
   if( enterCharacter == opts.triggerCharacter || enterCharacter == opts.levelCharacter){
   console.log("输入了$或者.");
   // 输入了 $,打开开关,允许检索事件执行
   searchStart = true;
   getOldCurrentPos(); // 输入 $ 的时候重置 oldCurrentPos
   }
   getSelectVal(); // 外度调用获取检索值方法 保证实时更新 selectVal 及 searchStart
   if( searchStart == true && checkCharacter == true && e.keyCode != 13 ){
   console.log("获取的值:"+selectVal);
   if( $.isFunction(opts.keyPressAction) ){   
    opts.keyPressAction(selectVal, function(arr_json){
    // 调用回调函数
    callbacktips(arr_json);   
    });
   }
   }
   if( e.keyCode == 13 ){ // 按enter键选取当前li文本值 重组输入框 value值
   var dropdownIsshow = _this.dropdown.css("display");
   if( dropdownIsshow == "block" ){ // 为了在下拉框隐藏时按 enter键 能换行,需要加上这个判断
    changeValue();
    console.log("这是点击enter后searchStart:"+searchStart);
   }
   }
   console.log("这是整个事件执行完成以后:"+searchStart);
  });

  鼠标滑入切换当前项         

 // 切换当前项
  _this.dropdown.on('mouseenter','li',function(){
   $(this).addClass("active").siblings().removeClass("active");
  });

  点击选取当前项 失去焦点事件

  这里采用了 event.target 方法来获得事件源,如果是 下拉框中的 li ,则执行 changeValue() 方法,否则结束检索事件 endSearch()         

 // 点击当前项获取文本值 重组输入框 value值 失去焦点时隐藏下拉框 清空下拉框
  $(document).click(function(e){
   var e = event || window.event;
   var el = e.target.localName; // 获取事件源 标签名
   el == "li" ? changeValue() : endSearch();
   //console.log(el);
  })

js代码 -- 动态获取光标位置

  这个方法是借鉴一位前辈的,这里附上原文地址(前辈大善):http://blog.csdn.net/kingwolfofsky/article/details/6586029

/*********以下为获取下拉框像素坐标方法*********/ 
  var kingwolfofsky = { 
   getInputPositon: function (elem) { 
   if (document.selection) { //IE Support 
    elem.focus(); 
    var Sel = document.selection.createRange(); 
    return { 
    left: Sel.boundingLeft, 
    top: Sel.boundingTop, 
    bottom: Sel.boundingTop + Sel.boundingHeight 
    }; 
   } else { 
    var that = this; 
    var cloneDiv = '{$clone_div}', cloneLeft = '{$cloneLeft}', cloneFocus = '{$cloneFocus}', cloneRight = '{$cloneRight}'; 
    var none = '<span style="white-space:pre-wrap;"> </span>'; 
    var div = elem[cloneDiv] || document.createElement('div'), focus = elem[cloneFocus] || document.createElement('span'); 
    var text = elem[cloneLeft] || document.createElement('span'); 
    var offset = that._offset(elem), index = this._getFocus(elem), focusOffset = { left: 0, top: 0 }; 
   
    if (!elem[cloneDiv]) { 
    elem[cloneDiv] = div, elem[cloneFocus] = focus; 
    elem[cloneLeft] = text; 
    div.appendChild(text); 
    div.appendChild(focus); 
    document.body.appendChild(div); 
    focus.innerHTML = '|'; 
    focus.style.cssText = 'display:inline-block;width:0px;overflow:hidden;z-index:-100;word-wrap:break-word;word-break:break-all;'; 
    div.className = this._cloneStyle(elem); 
    div.style.cssText = 'visibility:hidden;display:inline-block;position:absolute;z-index:-100;word-wrap:break-word;word-break:break-all;overflow:hidden;'; 
    }; 
    div.style.left = this._offset(elem).left + "px"; 
    div.style.top = this._offset(elem).top + "px"; 
    var strTmp = elem.value.substring(0, index).replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>').replace(/\s/g, none); 
    text.innerHTML = strTmp; 
   
    focus.style.display = 'inline-block'; 
    try { focusOffset = this._offset(focus); } catch (e) { }; 
    focus.style.display = 'none'; 
    return { 
    left: focusOffset.left, 
    top: focusOffset.top, 
    bottom: focusOffset.bottom 
    }; 
   } 
   },
   // 克隆元素样式并返回类 
   _cloneStyle: function (elem, cache) { 
   if (!cache && elem['${cloneName}']) return elem['${cloneName}']; 
   var className, name, rstyle = /^(number|string)$/; 
   var rname = /^(content|outline|outlineWidth)$/; //Opera: content; IE8:outline && outlineWidth 
   var cssText = [], sStyle = elem.style; 
   
   for (name in sStyle) { 
    if (!rname.test(name)) { 
    val = this._getStyle(elem, name); 
    if (val !== '' && rstyle.test(typeof val)) { // Firefox 4 
     name = name.replace(/([A-Z])/g, "-$1").toLowerCase(); 
     cssText.push(name); 
     cssText.push(':'); 
     cssText.push(val); 
     cssText.push(';'); 
    }; 
    }; 
   }; 
   cssText = cssText.join(''); 
   elem['${cloneName}'] = className = 'clone' + (new Date).getTime(); 
   this._addHeadStyle('.' + className + '{' + cssText + '}'); 
   return className; 
   }, 
   
   // 向页头插入样式 
   _addHeadStyle: function (content) { 
   var style = this._style[document]; 
   if (!style) { 
    style = this._style[document] = document.createElement('style'); 
    document.getElementsByTagName('head')[0].appendChild(style); 
   }; 
   style.styleSheet && (style.styleSheet.cssText += content) || style.appendChild(document.createTextNode(content)); 
   }, 
   _style: {}, 
   
   // 获取最终样式 
   _getStyle: 'getComputedStyle' in window ? function (elem, name) { 
   return getComputedStyle(elem, null)[name]; 
   } : function (elem, name) { 
   return elem.currentStyle[name]; 
   }, 
   // 获取光标在文本框的位置 
   _getFocus: function (elem) { 
   var index = 0; 
   if (document.selection) {// IE Support 
    elem.focus(); 
    var Sel = document.selection.createRange(); 
    if (elem.nodeName === 'TEXTAREA') {//textarea 
    var Sel2 = Sel.duplicate(); 
    Sel2.moveToElementText(elem); 
    var index = -1; 
    while (Sel2.inRange(Sel)) { 
     Sel2.moveStart('character'); 
     index++; 
    }; 
    } 
    else if (elem.nodeName === 'INPUT') {// input 
    Sel.moveStart('character', -elem.value.length); 
    index = Sel.text.length; 
    } 
   } 
   else if (elem.selectionStart || elem.selectionStart == '0') { // Firefox support 
    index = elem.selectionStart; 
   } 
   return (index); 
   }, 
   
   // 获取元素在页面中位置 
   _offset: function (elem) { 
   var box = elem.getBoundingClientRect(), doc = elem.ownerDocument, body = doc.body, docElem = doc.documentElement; 
   var clientTop = docElem.clientTop || body.clientTop || 0, clientLeft = docElem.clientLeft || body.clientLeft || 0; 
   var top = box.top + (self.pageYOffset || docElem.scrollTop) - clientTop, left = box.left + (self.pageXOffset || docElem.scrollLeft) - clientLeft; 
   return { 
    left: left, 
    top: top, 
    right: left + box.width, 
    bottom: top + box.height 
   }; 
   } 
  };

  调用获取坐标方法        

  // 调用获取坐标方法 show(elem)
  $(this).keyup(function(){
   show(this);
  });  
  // 调用 kingwolfofsky, 获取光标坐标
  function show(elem) { 
   var p = kingwolfofsky.getInputPositon(elem); 
   var s = _this.dropdown.get(0); 
   var ttop = parseInt(_this.css("marginTop"));
   var tleft = parseInt(_this.css("marginLeft"));
   s.style.top = p.bottom-ttop+10+'px'; 
   s.style.left = p.left-tleft + 'px';   
  }

js代码 -- 设置默认参数

var defaults = { 
 triggerCharacter : '$', // 默认触发事件 字符
 levelCharacter: '.', // 默认多层检索触发字符
 dropdownWidth:'150px' // 下拉框默认宽度
 };

js代码 -- 插件调用

  此处只为展示效果 在 keyPressAction 中能自定义匹配规则进行拓展   

 $("#test").editTips({
  triggerCharacter : '$',
  levelCharacter: '.',
  dropdownWidth:'150px', 
  keyPressAction:function(selectVal,callbacktips){
  var arr_json;
  if( selectVal == "" ){
   arr_json = ["a","ab","b","bb"]
  }
  if(selectVal && selectVal.indexOf("a")== 0){
   arr_json = ["a","ab"];
  }
  if(selectVal && selectVal.indexOf("b")== 0){
   arr_json = ["b","bb"];
  }
  if(selectVal && selectVal.indexOf("a.")== 0){
   arr_json = ["a.a","a.b","a.c"];
  }
  if(selectVal && selectVal.indexOf("b.")== 0){
   arr_json = ["b.a","b.b","b.c"];
  }
  if(selectVal && selectVal.indexOf("ab.")== 0){
   arr_json = ["ab.a","ab.b","ab.c"];
  }
  if(selectVal && selectVal.indexOf("bb.")== 0){
   arr_json = ["bb.a","bb.b","bb.c"];
  }
  callbacktips(arr_json);
  }  
 });

由于代码比较多,这里就不展示所有代码了,最终效果图:

js 公式编辑器 - 自定义匹配规则 - 带提示下拉框 - 动态获取光标像素坐标

在此附上demo下载链接:

不管你信不信,我已经设置了下载口令,亲们必须在心里说出我的一个优点才能点击下载~

下载demo

总结

以上所述是小编给大家介绍的js 公式编辑器 - 自定义匹配规则 - 带提示下拉框 - 动态获取光标像素坐标,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对网站的支持!