专栏首页Coding迪斯尼使用组件的state机制实现屏幕取词

使用组件的state机制实现屏幕取词

上一节我们完成了语法关键字高亮的功能。基本思路是,每当用户在编辑控件中输入字符时,组件就把控件里的代码提交给词法解析器,解析器分析出代码中关键字字符串的起始和结束位置,然后为每一个关键字字符串间套一个span标签,同时把span标签的字体属性设置成绿色,于是被span标签包裹的关键字就可以显示出绿色高亮了。

然而这种做法存在一个严重问题,就在于如果每输入一个字符,解析器就得把所有代码重新解析一遍,如果当前代码量很大,那么这种办法效率就相当低下。这里我们先解决这个问题。事实上,当用户输入代码时,受到影响的只不过是当前所输入那行,其他行是没有变化的,因此我们只需要获取用户当前输入那一行代码,单就这一行代码进行词法解析,然后就这一行重新实现语法高亮,那么整体效率就能够得到极大的提升了。

由此,我们当前面临一个难题是,如何得到当前正在输入的那一行信息。我们的编辑控件是一个div组件,一开始,组件中没有任何内容,如果我们向它输入一行字符串”let g = 0;”,那么div组件下的html内容如下:

<div>
  <text>let g = 0</text>
</div>

如果接着我们按下回车键,换一行后,再输入字符串”let f = 1;”,那么此时div控件里面的html会变成如下格式:

<div>
  <text>let g = 0</text>
  <div><text>let f = 1;</text></div>
</div>

新的一行则包裹在另一个div标签中,我们可以利用这个特性,实现将鼠标所在的那行信息抽取出来。每当有输入到div控件时,我们就检测当前所在的text节点,它是否包含一在一个span节点中,如果没有,那么我们就为其添加一个span节点,当我们想要抽取某一行的信息时,我们就找到对应的span节点,把该节点包裹的信息拿出来就可以了,例如上面的html代码,我们需要改造成如下形式: (为了简便,我们暂时忽略关键字应该包裹在一个span标签里)

<div>
  <span class="LineSpan line0"><text>let g = 0</text></span>
  <div><span class="LineSpan line1"><text>let f = 1;</text></span></div>
</div>

这样一来,当我们想要获得第一行的字符串,我们只要查找属性含有line0的span元素,从该元素的子节点中就可以得到第一行的内容。于是获得鼠标所在行字符串的代码实现如下:

getCaretLineNode() {
        var sel = document.getSelection()
        //得到光标所在行的node对象
        var nd = sel.anchorNode
        //查看其父节点是否是span,如果不是,
        //我们插入一个span节点用来表示光标所在的行
        var elements = document.getElementsByClassName(this.lineSpanNode)
        for (var i = 0; i < elements.length; i++) {
            var element = elements[i]
            if (element.contains(nd)) {
                while (element.classList.length > 0) {
                    element.classList.remove(element.classList.item(0))
                }
                element.classList.add(this.lineSpanNode)
                element.classList.add(this.lineNodeClass + i)
                return element
            }
        }

        //计算一下当前光标所在节点的前面有多少个div节点,
        //前面的div节点数就是光标所在节点的行数
        var divElements = this.divInstance.childNodes;
        var l = 0;
        for (var i = 0; i < divElements.length; i++) {
            if (divElements[i].contains(nd)) {
                l = i;
                break;
            }
        }

        var spanNode = document.createElement('span')
        spanNode.classList.add(this.lineSpanNode)
        spanNode.classList.add(this.lineNodeClass + l)
        nd.parentNode.replaceChild(spanNode, nd)
        spanNode.appendChild(nd)
        return spanNode
    }

document.getSelection() 获得当前光标闪烁时所在的节点,也就是代码中的nd, 接着我们找出所有含有属性为”LineSpan”的span节点,其中this.lineSpanNode对应的就是字符串”LineSpan”,接着对每一个span元素,看看它的子元素是否包含光标所在的元素nd,如果包含了,那表明当前行已经成功添加了span父节点,同时计算当前元素前面的span节点有几个,进而得出当光标在第几行,因为每一行所在行数其实是动态可变的,如果当前行是第3行,我们在上一行按回车,然后添加一行,那么原来的第3行就得变成第4行,代码最开始的for循环就是要检测这种情况。

如果当前光标所在元素没有一个对应的span父节点,那么我们就得为当前行增加一个span父节点,此时我们先找出所有div节点,每一个div节点意味着一行,通过计算包含当前光标节点的div节点前面有几个div节点,我们就可以确定当前光标在第几行。接着我们构造一个新的span节点,并为该节点添加相应的class属性,然后把当前光标所在节点当做span节点的子节点添加到DOM中。

接下来修改onDivContentChane函数,当每次有按键输入时,我们不再将所有代码提交给词法解析器去分析,而是把光标所在行的字符串抽出来,提交给解析器去分析,这样效率就可以大大提升了,代码修改如下:

onDivContentChane(evt) {
....
var currentLine = this.getCaretLineNode()
....
this.changeNode(currentLine)
....
}

接下来,我们要完成一个特性是实现屏幕取词功能,如果你使用VS或Eclipse进行单步代码调试时,你把鼠标挪动到某个变量字符串上,那么IDE会弹出一个窗口,给你显示出鼠标所在变量的值或相关信息。此外不少翻译软件,当你把鼠标挪动到某个单词上时,界面会在鼠标旁边弹出一个窗口,显示该单词的中文解释,这种功能就叫做鼠标取词,完成后,我们页面效果如下:

当我们把鼠标挪动到变量f上时,在鼠标旁边弹出一个窗口,里面显示的是f这个变量对应的token信息。右边弹出的窗口是由bootstrap组件popover来实现的。实现这个功能的基本思路如下:

1, 解析代码,确定代码中类型为IDENTIFIER字符串的起始和结束位置。 2, 在根据起始和结束位置,我们给该字符串添加一个span父节点 3, 把当前变量字符串对应的token对象和添加的span父节点对象关联起来。 4,相应span节点的mouseenter 和 mouseleave消息. 5,一旦鼠标挪动到字符串上时,span节点的mouseenter事件触发,我们响应该事件时,弹出popover窗口,一旦鼠标离开我们就关闭popover窗口。

第一步的实现与我们前面实现的关键字高亮算法是一样的,只不过有些环节需要处理。一种情况是,当前输入行不含关键字时,例如:

five = 5; six = 6; seven = 7;

上面代码行对应的html代码如下:

<text>five = 5; six = 6; seven = 7;</text>

我们用词法解析器解析改行代码,得到三个变量five , six , seven的起始和结束位置,通过这些位置给他们插入span标签:

<text><span class="Identifier">five</span> = 5; <span class="Identifier">six</span> = 6; <span class="Identifier">seven</span> = 7;</text>

如果当前代码行包含关键字的话,那就得特殊处理,例如语句:

let five = 5; let six = 6; let seven = 7

它对应的html代码为:

<span style="color:green">let</span> <text>five = 5</text><span style="color:green">let</span><text> six = 6;</text><span style="color:green">let</span><text> seven = 7</text>

由于关键字高亮时,程序会把夹在关键字中的代码切割成若干部分,就像上面那样,这种情况,我们就需要把上面各个text标签包裹的字符串提交给词法解析器去解析,于是相关代码修改如下:

changeNode(n) {
      var f = n.childNodes; 
      for(var c in f) {
          this.changeNode(f[c]);
      }
      if (n.data) {
          this.lastBegin = 0
          n.keyWordCount = 0
          //change here
          n.identifierCount = 0
          var lexer = new MonkeyLexer(n.data)
          this.lexer = lexer
          lexer.setLexingOberver(this, n)
          lexer.lexing()
      } 
    }

notifyTokenCreation(token, elementNode, begin, end) {
        // change here
        var e = {}
        e.node = elementNode
        e.begin = begin
        e.end = end
        e.token = token

        if (this.keyWords[token.getLiteral()] !== undefined) {
            elementNode.keyWordCount++;
            this.keyWordElementArray.push(e)
        }

        if (elementNode.keyWordCount == 0 && token.getType() === this.lexer.IDENTIFIER) {
            elementNode.identifierCount++
            this.identifierElementArray.push(e)
        }
    }

每当解析器解析到一个token时,代码会检测当前token类型是否是IDENTIFIER,如果是,并且当前代码不包含关键字,也就是elementNode.keyWordCount == 0, 那么就把当前解析得到的相关信息压入数组identifierElementArray。在给关键字添加span标签时,我们会把夹在关键字中的其他代码字符串单独创建成一个text节点,这些text节点中很可能包含了IDENTIFIER类型的变量,于是我们需要把这些节点提交给解析器去分析,因此代码修改如下:

hightLightKeyWord(token, elementNode, begin, end) {
  var strBefore = elementNode.data.substr(this.lastBegin, 
                         begin - this.lastBegin)
  strBefore = this.changeSpaceToNBSP(strBefore)

  var textNode = document.createTextNode(strBefore)
  var parentNode = elementNode.parentNode
  parentNode.insertBefore(textNode, elementNode)
  //change here
  this.textNodeArray.push(textNode)
  ....
}

hightLightSyntax() {
  var i
  //change here
  this.textNodeArray = []
  for (i = 0; i < this.keyWordElementArray.length; i++) {
     ....
     if (this.currentElement.keyWordCount === 0) {
     if (this.currentElement.keyWordCount === 0) {
         var end = this.currentElement.data.length
         var lastText = this.currentElement.data.substr(this.lastBegin, 
                                end)
         lastText = this.changeSpaceToNBSP(lastText)
         var parent = this.currentElement.parentNode
         var lastNode = document.createTextNode(lastText)
         parent.insertBefore(lastNode, this.currentElement)
         // change here
        // 解析最后一个节点,这样可以为关键字后面的变量字符串设立popover控件
         this.textNodeArray.push(lastNode)
         parent.removeChild(this.currentElement)
  }
  ....
}

上面代码是原来用于解析关键字并实现高亮效果的,我们增加一些新代码,目的就是把关键字解析时,夹在关键字中的代码提交给词法解析器解析,并识别出其中的表示变量的字符串,把这些字符串及其对应的token收集到数组textNodeArray中,这些信息收集完毕后,我们就可以实现屏幕取词功能了,代码如下:

addPopoverSpanToIdentifier(token, elementNode, begin, end) {
        var strBefore = elementNode.data.substr(this.lastBegin, 
                         begin - this.lastBegin)
        strBefore = this.changeSpaceToNBSP(strBefore)
        var textNode = document.createTextNode(strBefore)
        var parentNode = elementNode.parentNode
        parentNode.insertBefore(textNode, elementNode) 

        var span = document.createElement('span')
        span.onmouseenter = (this.handleIdentifierOnMouseOver).bind(this)
        span.onmouseleave = (this.handleIdentifierOnMouseOut).bind(this)
        span.classList.add(this.identifierClass)
        span.appendChild(document.createTextNode(token.getLiteral()))
        span.token = token
        parentNode.insertBefore(span, elementNode)
        this.lastBegin = end - 1
        elementNode.identifierCount--
    }

    addPopoverByIdentifierArray() {
        //该函数的逻辑跟hightLightSyntax一摸一样
        for (var i = 0; i < this.identifierElementArray.length; i++) {
            //用 span 将每一个变量包裹起来,这样鼠标挪上去时就可以弹出popover控件
            var e = this.identifierElementArray[i]
            this.currentElement = e.node
            //找到每个IDENTIFIER类型字符串的起始和末尾,给他们添加span标签
            this.addPopoverSpanToIdentifier(e.token, e.node, 
            e.begin, e.end)

            if (this.currentElement.identifierCount === 0) {
                var end = this.currentElement.data.length
                var lastText = this.currentElement.data.substr(this.lastBegin, 
                                end)
                lastText = this.changeSpaceToNBSP(lastText)
                var parent = this.currentElement.parentNode
                var lastNode = document.createTextNode(lastText)
                parent.insertBefore(lastNode, this.currentElement)
                parent.removeChild(this.currentElement)
            }
        }

        this.identifierElementArray = []
    }
    preparePopoverForIdentifers() {
        if (this.textNodeArray.length > 0) {
            for (var i = 0; i < this.textNodeArray.length; i++) {
                //将text 节点中的文本提交给词法解析器抽取IDENTIFIER
                this.changeNode(this.textNodeArray[i])
                //为解析出的IDENTIFIER字符串添加鼠标取词功能
                this.addPopoverByIdentifierArray()
            }
            this.textNodeArray = []
        } else {
            this.addPopoverByIdentifierArray()
        }

    }

上面代码的逻辑与实现关键词高亮几乎是一模一样的。都是把相应字符串抽出来,给它用一个span标签给包裹上,同时我们添加对span标签两种事件的响应,一个是mouseenter消息,也就是当鼠标挪动到span标签时产生的事件,灵感是mouseleave,也就是鼠标挪开span标签时,我们需要响应的事件。于是当mouseenter发生时,我们就可以在鼠标旁边弹出popover控件,当mouseleave发送时,我们就把popover控件给关闭掉,这样一来我们就可以实现屏幕取词的效果了。

现在我们看看上面的popover控件是如何弹出的,由于它是boostrap提供的控件,因此我们在组件的render()函数中需要把它添加进来:

render() {
        let textAreaStyle = {
            height: 480,
            border: "1px solid black"
        };
        //change here
        return (
            <div>
              <div style={textAreaStyle} 
              onKeyUp={this.onDivContentChane.bind(this)}
              ref = {(ref) => {this.divInstance = ref}}
              contentEditable>
              </div>

               <bootstrap.Popover placement = {this.state.popoverStyle.placement}
               positionLeft = {this.state.popoverStyle.positionLeft}
               positionTop = {this.state.popoverStyle.positionTop}
               title = {this.state.popoverStyle.title}
                >
                  {this.state.popoverStyle.content}
                </bootstrap.Popover>
            </div>
            );
    }

注意看,代码中用到一个对象叫this.state,这是reactjs组件一个相当重要的内置成分,它与上节我们提到的props属性相当。单页应用开发有一个难点就在于如何让程序底层数据与外在界面的展示实现实时联动。比如说我在程序底层有一个数据叫counter, 它的值是1,在页面上就可以把这个值显示出来。如果程序运行时,counter 的值变成了2,在变化的那一刻页面上显示的信息也要立刻变成2,这种底层数据和外层UI的实时联动是所以web框架都必须解决的问题,reactjs解决这个难题依赖的就是state内置变量。

大家看上面代码,popover控件的很多属性是跟state内部的变量绑定起来的,例如:

positionTop = {this.state.popoverStyle.positionTop}

也就是popover控件显示时的高度跟state变量里面的popoverStyle.positionTop这个变量绑定一起了。这样就产生了一种联动效果,如果this.state.popoverStyle.positionTop的值是10,那么popover控件在页面上显示时,它的高度是10px处,如果我们在代码中改变this.state.popoverStyle.positionTop的值,使他变成20,这个改动就会里面反应到页面显示上,也就是popover控件的窗体会自动下架10个单位,在高度为20px的位置上显示。这种联动性能极大的降低我们开发程序的难度,更详细的讲解和代码调试演示过程,请点击链接。

在组件启动时,我们先把popover窗体挪动到界面之外,让用户看不到它的存在,一旦用户把鼠标挪动到某个变量字符串上时,包裹着变量字符串的span它会触发mouseenter事件,在响应该事件时,我们得到鼠标当前所在的位置,然后把popover控件挪动到鼠标旁边,并把popover控件中的信息显示成变量对应的token,相关代码如下:

constructor(props) {
  super(props)
  ....
  // change here
  this.identifierElementArray = []
  this.textNodeArray = []
  this.lineNodeClass = 'line'
  this.lineSpanNode = 'LineSpan'
  this.identifierClass = "Identifier"
  this.spanToTokenMap = {}
  this.initPopoverControl()
}

initPopoverControl() {
  this.state = {}
  this.state.popoverStyle = {}
  this.state.popoverStyle.placement = "right"
  this.state.popoverStyle.positionLeft = -100
  this.state.popoverStyle.positionTop = -100
  this.state.popoverStyle.content = ""
  this.setState(this.state)
}

 // change here
 handleIdentifierOnMouseOver(e) {
        e.currentTarget.isOver = true
        var token = e.currentTarget.token
        this.state.popoverStyle.positionLeft = e.clientX + 5
        this.state.popoverStyle.positionTop = e.currentTarget.offsetTop - e.currentTarget.offsetHeight
        this.state.popoverStyle.title = "Syntax"
        this.state.popoverStyle.content = "name:" + token.getLiteral() + "\nType:" + token.getType()
        + "\nLine:" + e.target.parentNode.classList[1]
        this.setState(this.state)
    }

  handleIdentifierOnMouseOut(e) { 
     this.initPopoverControl()
  }

在组件初始化时,我们先调用initPopoverControl()函数,该函数是对this.state.popoverStyle对象的初始化,设置为相关内容后,这里一定要注意,修改完state变量的内容后,一定要调用setState函数,把修改后的state对象提交给reactjs框架。

我们前面说过,组件的state对象是内置的,它用来把底层数据跟外层UI绑定起来,如果它改变了,外层UI会根据改变后的底层数据进行显示,但代码内部改变state变量的内容后,必须调用setState函数通知reactjs框架,这样框架才能及时帮我们更新与底层数据绑定的UI展示。

在上面代码中,我们把popover控件的placement, positionLeft, positionTop三个属性跟state对象中的state.popoverStyle.placement, state.popoverStyle.positionLeft, state.popoverStyle.positionRight绑定起来,state变量部分的数据变动后,通过setState()提交给框架,那么popover 控件的相关属性就会自动改变,从而控件窗体会在页面上根据数据的改变而作相应的变动。

本文分享自微信公众号 - Coding迪斯尼(gh_c9f933e7765d),作者:陈屹

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2017-12-01

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • VUE+WebPack游戏设计:欲望都市城市图层的设计

    望月从良
  • 实现小球在弹射前的拉伸特效和动态障碍物特效

    当前我们实现小球弹射时,会先用鼠标点击小球,然后移动鼠标,当松开鼠标时,小球会弹射向鼠标松开的位置。我们按住小球的时间越长,小球弹射的力度就越大,但有一个问题是...

    望月从良
  • 寿司开卖:实现寿司制作特效和音响特效

    本节我们将继续上一节完成若干个小功能。首先要完成的是,当客户动画在主页面出现时,它左上角会冒泡,显示它想购买何种寿司,此时玩家可以点击左下角面板中各种元素,组合...

    望月从良
  • 用 React 分页显示数据用react分页显示数据

    展示一下主要三个组件:父组件listBox、列表组件List、按钮组件PageButton

    一个会写诗的程序员
  • 上拉加载下拉刷新了解下

    1.界面上,只分成简单的两块,一块是上方的刷新文字,一块是下方的内容,然后将上方提示内容隐藏在屏幕之外,一般由两种方式,一种是上面遮一层,另一种是marginT...

    IMWeb前端团队
  • React源码分析与实现(一):组件的初始化与渲染

    阅读源码的方式有很多种,广度优先法、调用栈调试法等等,此系列文章,采用基线法,顾名思义,就是以低版本为基线,逐渐了解源码的演进过程和思路。

    Nealyang
  • ol5里面实现相册地图

    如下图,在手机里面有一个这样的功能,我称之为“相册地图”,本文讲述的是通过扩展ol.style的类,来实现“相册地图”这个功能。

    lzugis
  • Angular6自定义表单控件方式集成Editormd

    曾经找到过“Editor.md”,看之心喜,一直想在Angular中集成下这款markdownpad编辑器玩,在网上也只找到一篇通过指令集成的,虽然可以实现,但...

    汐楓
  • canvas 弹球

    mySoul
  • Flash在线拍摄用户头象

    很多网站在上传用户头象时,除了传统方式上传外,都支持在线摄像头拍照并做简单编辑,完成之后再将图象数据提交到服务端(比如ASP.Net),这几天正好需要这个功能,...

    菩提树下的杨过

扫码关注云+社区

领取腾讯云代金券