美文网首页这是前端嘛Web前端之路
#5 从零开始制作在线 代码编辑器

#5 从零开始制作在线 代码编辑器

作者: 春雨棲姬 | 来源:发表于2017-08-08 01:22 被阅读241次

    上一篇
    #4 从零开始制作在线 代码编辑器

    删除 与 BackSpace 与 Delete


    BackSpace

    为了方便. 这里所说的删除的只考虑以下两种键的最简单的删除行为:

    1. BackSpace 往左边删除一个字符
    2. Delete 往右边删除一个字符

    有选区状态时所做的删除或者替换. 这里不考虑哦.

    因为做了简化. 这里的流程就会比较简单. 要说明的是:
    当光标位于行首时. 再使用BackSpace 的时候. 要删除当前行.
    把这个方法加在 harusame-line.js

    code

    @path serval/script/harusame-line.js
    // 只有部分代码
    
    /**
     * 删除指定行
     */
    self.deleteLine = function (v_line_number) {
        var $line_container = document.querySelector('.line-container') // 同样. 这里暂时这么写...
        var $line = self.getLineContentByLogicalY(v_line_number).parentNode.parentNode
        $line_container.removeChild($line)
    }
    
    @path serval/script/harusame-serval.js
    // 只有部分代码
      
    /**
     * KEY: BackSpace  
     * 0. 阻止默认行为
     * 1. 如果光标在首行首列, 什么都不干
     * 2. 如果光标在该行第0个位置
     *      2.1. 得到光标后的内容(left_content), 删除当前行
     *      2.2. 修正行号 -> 暂时是这样
     *      2.3. 光标上移一行, 且放置到该行最后一列
     *      2.4. 将上一行的残留下来的内容(left_content) 追加到该行末尾
     * 3. 其他情况下
     *      3.1. 光标往左移动一列
     *      3.2. 删除一个字符
     */
    Serval.prototype.keydownHandler.'8': function (event) {
        event.preventDefault() /* 0 */
        var self = this
        self.allocTask(function (v_cursor) {
            var $line = v_cursor.line.$line_content
            var textContent = $line.textContent
            var logicalX = v_cursor.logicalX
            var left_content = textContent.substring(logicalX, textContent.length)
    
            var logicalY = v_cursor.logicalY
            if (logicalY === 0) {
                if (logicalX === 0) {
                    return /* 2 */
                }
            } else {
                if (logicalX === 0) { /* 2 */
                    Line.deleteLine(v_cursor.logicalY) /* 2.1 */
                    Line.fixLineNumber(v_cursor.logicalY) /* 2.2 */
    
                    v_cursor.logicalY -= 1 /* 2.3 */
                    v_cursor.logicalX = v_cursor.line.$line_content.textContent /* 2.3 */
    
                    var $line = v_cursor.line.$line_content
                    var textContent = $line.textContent
                    $line.textContent = textContent + left_content /* 2.4 */
    
                    return
                }
            }
    
            v_cursor.logicalX -= 1 /* 3.1 */
            $line.textContent = textContent.substring(0, logicalX - 1) + left_content /* 3.2 */
        })
    },
    
    

    效果是这样... 很普通. 见 图5-1

    图5-1.gif

    Delete

    Delete 的原理同 BackSpace. 但是还是有一些差异. 在做的时候. 能感受到之前代码的不合理(蠢).

    Delete 中. 当光标位于 最后一行 最后一列 时. 需要先得到一共有几行. 才能做比较. 可是问题在于访问 Line.max_line_number 会触发它的 getter. 并且修改了数据. 见 图5-2. 这是一个看似简单但是会充满麻烦的行为. 毕竟每次访问. 他都在变. 导致在做 最后一行 最后一列 的判断时. 只要按下 一次以上的 Delete. 这个判断就会失效.

    图5-2.png

    先把问题补了吧...

    1. ++ 改成 删掉 (这里暂时这么改... 事实上没有必要控制 getter setter 了)
    1. 每次调用 Line.generateLine 的时候. 最大行数加一.
    1. 同样地. 每次调用Line.deleteLine的时候. 最大行数减一.

    code

    @path serval/script/harusame-serval.js
    // 部分代码
    
    /**
     * KEY: Delete
     * 0. 阻止默认行为
     * 1. 如果光标在最后一行最后一列, 什么都不干
     * 2. 如果光标在该行最后一个位置
     *      2.1. 得到下一行的光标后的内容(left_content, 另外 也肯定是该行全部的内容)
     *      2.2. 删除下一行
     *      2.3. 修正行号 -> 暂时是这样
     *      2.4. 将 left_content 内容 追加到当前行末尾
     * 3. 其他情况下
     *      3.1. 向右删除一个字符
     */
    Serval.prototype.keydownHandler.  '46': function (event) {
        event.preventDefault() /* 0 */
        var self = this
        self.allocTask(function (v_cursor) {
            var $line = v_cursor.line.$line_content
            var textContent = $line.textContent
            var logicalX = v_cursor.logicalX
            var left_content = textContent.substring(logicalX + 1, textContent.length)
    
            var logicalY = v_cursor.logicalY
            var max = Line.max_line_number - 1
            console.log('max', max)
            if (logicalY === max) {
                if (logicalX === textContent.length) {
                    return /* 1 */
                }
            } else {
                if (logicalX === textContent.length) {
    
                    var left_content = Line.getLineContentByLogicalY(logicalY + 1).textContent /* 2.1 */
                    Line.deleteLine(logicalY + 1) /* 2.2 */
                    Line.fixLineNumber(v_cursor.logicalY) /* 2.3 */
    
                    $line.textContent = v_cursor.line.$line_content.textContent + left_content /* 2.4 */
                    return
                }
            }
            $line.textContent = textContent.substring(0, logicalX) + left_content /* 3.1 */
        })
    },  
    

    看看效果的说. 很普通. 见图 5-3. 顺便又试了下 BackSpace

    图5-3.gif

    选区 与 selection


    在进行复制等操作前. 需要让计算姬知道哪些对象需要操作. 选区就是这样一个东西. 感觉也没什么好说的.

    编写选区的整体思路

    鉴于常识与操作习惯. 这里规定一个光标只能有一个选区 并且 选择的内容必须是连续的.

    对于一个选区... 只要

    1. 拥有起点与终点. 由于区域已经规定必须是连续的. 那么
    2. 他所包含的区域就可以计算出来.
    3. 选区中的内容才可以获得.
    4. 选区也才可以绘制出来.

    但是有个问题!

    可能很早之前说了.? 终点总是光标的当前位置. 所以只要记下选区的起点就行了.

    然而所说的起点并不是 鼠标按下时候(onmousedown)的那个点.! 这里为了方便记录. 把鼠标按下时候的那个点叫做 基准点 ..

    因为选区可能是从 基准点开始往左/左上角拉 或者 往右/右下角拉. 图5-4 这样.

    图5-4.gif

    需要做一个判断来确定选区真正的 起点 和 终点. 这样

    1. 绘制选区才会比较方便的找准点...
    2. 截取内容时. 才会正确?.(下面会说这个问号是为什么)

    获得选区内容

    观察已有的编辑器功能. 可以了解到选区的创建方式:

    1. 按下 鼠标的时候确定一个选区基准点
    2. 拖动 鼠标来选取内容. (也就是根据选区基准点 与 光标当前位置 计算选区起点与终点. 之后根据起点与终点绘制视图).
    3. 放开 鼠标后确定选取的内容. (实际上步骤同2)

    这里也刚好对应三个常用的鼠标事件:

    1. 按下 - onmousedown
    2. 拖动 - onmousemove
    3. 放开 - onmouseup

    具体功能逻辑的话... 其实原理很简单.. 只是由于设计上的问题. 导致代码暂时有点臃肿. 就直接通过代码来显示了.

    绘制选区视图

    最后是关于选区的视图...

    根据选区必须是连续的特性以及盒子模型一般是矩形的特性... 可以将选区分为三种类型 以方便地适应所有情况. 见 图5-5.

    图5-5.png
    1. 只有一行的: 用一个带背景颜色的<div class="selection-part" /> 并控制他的偏移量与宽度 来显示
    2. 只有二行的: 那就用二个<div class="selection-part" />
    3. 多于三行的: 无论多少行都可以用三个来表示<div class="selection-part" />

    记得最初的时候创建了selected-container.

    就把 <div class="selection-part" /> 放进这层中. 另外 selected-container 这名字好有问题... 不如改成 selection. 再加上这几个 container 有明确的覆盖关系... 最终会改成 selection-layer 这种形式 可能比较好一点... 这里暂时不会改

    code - 获得选区内容

    首先来绑定事件... 因为逻辑比较清晰啦... 这里是就直接先调用还不存在的函数... 然后再去写函数内部的具体逻辑..(从界面进入到逻辑)

    @path serval/script/harusame-serval.js
    // 目标代码
    
    Serval.prototype._bindMouseEvent: function () {
        var self = this
    
        var isMouseDown = false
    
        /**
         * addEventListener 是指自己写的方法,见最下面
         * 当 mousedown 时,就对光标位置进行计算
         * 1. 取消鼠标默认的行为,否则 2 不会生效
         * 2. 让编辑器总是能够接受键盘事件
         * 3. 定位鼠标
         * 4. 设置选区基准点
         * 5. 记忆鼠标已经点击还未弹起, 用来避免鼠标没有点击就一直更新选区
         */
        addEventListener(self.$serval_container, 'mousedown', function (event) {
            event.preventDefault() /* 1 */
    
            self.$inputer.focus() /* 2 */
    
            self.allocTask(function (v_cursor) {
                v_cursor.psysicalY = event.layerY /* 3 */
                v_cursor.psysicalX = event.layerX /* 3 */
    
                v_cursor.setSelectionBase() /* 4 */
            })
    
            isMouseDown = true /* 5 */
        })
    
        /**
         * 这里先不管触发的频次是否频繁什么的...
         * 1. 当 mousemove 时 且 鼠标 按下 时,更新光标位置
         * 2. 更新选区起点 与 终点 与 视图
         */
        addEventListener(self.$serval_container, 'mousemove', function (event) {
            event.preventDefault()
            if (isMouseDown) { 
                self.allocTask(function (v_cursor) {
                    v_cursor.psysicalY = event.layerY /* 1 */
                    v_cursor.psysicalX = event.layerX /* 1 */
    
                    v_cursor.updateSelection() /* 2 */
                })
            }
        })
    
        /**
         * 1. 标记鼠标已经弹起
         * 2. 更新光标位置
         * 3. 更新选区起点 与 终点 与 视图
         * 4. 当存在选区的时候
         *      4.1. 获得选区内容, ---> 这里先测试下是否能获取到选区内容 <---
         */
        addEventListener(self.$serval_container, 'mouseup', function (event) {
            event.preventDefault()
    
            isMouseDown = false /* 1 */
    
            self.allocTask(function (v_cursor) {
                v_cursor.psysicalY = event.layerY /* 2 */
                v_cursor.psysicalX = event.layerX /* 2 */
    
                v_cursor.updateSelection() /* 3 */
    
                if (v_cursor.isSelectionExist()) { /* 4 */
                    console.log(v_cursor.getSelectionContent()) /* 4.1 */
                }
            })
        })
    },
    
    @path serval/script/harusame-cursor.js
    
    /**
     * 设定选区基准点
     */
    Cursor.prototype.setSelectionBase: function () {
        this.mousedown_point = {
            logicalY: this.logicalY,
            logicalX: this.logicalX,
            psysicalY: this.psysicalY,
            psysicalX: this.psysicalX
        }
    },
    
    /**
     * 更新选区
     */
    Cursor.prototype.updateSelection: function () {
        this.findSelection()
        // this.updateSelectionView() 用来更新选区视图
    },
    
    /**
     * 找到选区的 起点 与 终点
     * @ 这个函数名字不怎么合适.. 而且代码很丑..
     * @ 如果把坐标单独做成一个类 就会好看很多(大概
     * @ 甚至可以比如 point_end = this.mousedown_point
     * @ 但是就现在来说的话干脆就让他更臃肿 反而好看点.(大概 
     * @ _(:3」∠)_
     */
    Cursor.prototype.findSelection: function () {
        var point_start = {}
        var point_end = {}
    
        if (this.logicalY < this.mousedown_point.logicalY) {
            point_start.logicalY = this.logicalY
            point_start.logicalX = this.logicalX
            point_start.psysicalY = this.psysicalY
            point_start.psysicalX = this.psysicalX
    
            point_end.logicalY = this.mousedown_point.logicalY
            point_end.logicalX = this.mousedown_point.logicalX
            point_end.psysicalY = this.mousedown_point.psysicalY
            point_end.psysicalX = this.mousedown_point.psysicalX
        } else if (this.logicalY === this.mousedown_point.logicalY) {
            if (this.logicalX < this.mousedown_point.logicalX) {
                point_start.logicalY = this.logicalY
                point_start.logicalX = this.logicalX
                point_start.psysicalY = this.psysicalY
                point_start.psysicalX = this.psysicalX
    
                point_end.logicalY = this.mousedown_point.logicalY
                point_end.logicalX = this.mousedown_point.logicalX
                point_end.psysicalY = this.mousedown_point.psysicalY
                point_end.psysicalX = this.mousedown_point.psysicalX
            } else {
                point_start.logicalY = this.mousedown_point.logicalY
                point_start.logicalX = this.mousedown_point.logicalX
                point_start.psysicalY = this.mousedown_point.psysicalY
                point_start.psysicalX = this.mousedown_point.psysicalX
    
                point_end.logicalY = this.logicalY
                point_end.logicalX = this.logicalX
                point_end.psysicalY = this.psysicalY
                point_end.psysicalX = this.psysicalX
            }
        } else {
            point_start.logicalY = this.mousedown_point.logicalY
            point_start.logicalX = this.mousedown_point.logicalX
            point_start.psysicalY = this.mousedown_point.psysicalY
            point_start.psysicalX = this.mousedown_point.psysicalX
    
            point_end.logicalY = this.logicalY
            point_end.logicalX = this.logicalX
            point_end.psysicalY = this.psysicalY
            point_end.psysicalX = this.psysicalX
        }
    
        this.selection_start = point_start
        this.selection_end = point_end
    },
    
    /**
     * 判断是否有选区
     */
    Cursor.prototype.isSelectionExist: function () {
        if (this.logicalY === this.mousedown_point.logicalY && this.logicalX === this.mousedown_point  .logicalX) {
            return false
        }
        return true
    },
    
    /**
     * 获得选区内容
     * 1. 如果选区只有一行
     *      1.1. 截取 起点 与 终点 的内容,且不需要换行
     * 2. 如果选区只有二行
     *      2.1. 截取 起点 到 起点行末尾 的内容
     *      2.2. 截取 终点行开始 到 终点 的内容,且不需要换行
     * 3. 如果选区大于二行
     *      3.1. 截取 起点 到 起点行末尾 的内容
     *      3.2. 遍历除了 起点行 与 终点行 的其他行
     *          3.2.1. 截取该行的整段内容
     *      3.3. 截取 终点行开始 到 终点 的内容,且不需要换行
     */
    getSelectionContent: function () {
        var point_start = this.selection_start
        var point_end = this.selection_end
    
        var result = ''
        var count = point_end.logicalY - point_start.logicalY
        var start_line_text = Line.getLineContentByLogicalY(point_start.logicalY).textContent
    
        /* 1 */
        if (count === 0) {
            console.log('--> 选区类型 : 一行 <--')
            result += start_line_text.substring(point_start.logicalX, point_end.logicalX) /* 1.1 */
    
        /* 2 */
        } else if (count === 1) {
            console.log('--> 选区类型 : 二行 <--')
            result += start_line_text.substring(point_start.logicalX, start_line_text.length) + '\n' /* 2.1 */
    
            var end_line_text = Line.getLineContentByLogicalY(point_end.logicalY).textContent
            result += end_line_text.substring(0, point_end.logicalX) /* 2.2 */
    
        /* 3 */
        } else {
            console.log('--> 选区类型 : 多行 <--')
            result += start_line_text.substring(point_start.logicalX, start_line_text.length) + '\n' /* 3.1 */
    
            /* 3.2 */
            for (var i = point_start.logicalY + 1; i < point_end.logicalY; i++) {
                result += Line.getLineContentByLogicalY(i).textContent + '\n' /* 3.2.1 */
            }
    
            var end_line_text = Line.getLineContentByLogicalY(point_end.logicalY).textContent
            result += end_line_text.substring(0, point_end.logicalX) /* 3.3 */
        }
    
        return result
    },
    

    就是这样.先来调试一下.保证选区数据是返回正确的再来做视图哦... 来看看效果... 见图5-6.

    图5-6.gif

    ... 嗯嗯.. 内容能用各种姿势获取到.~

    这里额外说一下... 好早之前的版本中忘记做了单行选区的 选区起点 终点的判断
    会产生比如这样的事情 textContent.substring(6, 0). 但这并没有报错...

    见挺靠谱的文档 MDN 其中说到了

    If indexStart is greater than indexEnd, then the effect of substring() is as if the two arguments were swapped; for example, str.substring(1, 0) == str.substring(0, 1).

    感觉有点神奇... 会做这样的处理...

    code - 获得选区内容

    确保选区的数据获得是正确的之后. 来尝试做选区的视图部分...

    简化的选区视图 编辑器中的选区视图

    之前也说过.. 这里的选区最多只会划分为三段... 这是为了防止操作太多的DOM起见...(偷懒.
    当然像一般的编辑器那样. 每一行单独一段高亮的选区也不是不行啦... 只是还没做就感觉会卡(hen)卡(ma)的(fan).

    CSS

    为了能快地看到成型后的效果. 先以最快速度把<div class="selection-part" /> 塞进 <div class="selected-container /> 中. 再来做 js 的部分.

    1. 因为选区的样式已经很直观了... 这里就先写的 css.
    .selection-content {
        position: absolute;
        top: 0;
        left: 0; 
    
        right: 0; 
        /* 
         * 这里用 right: 0 让宽度铺满一行
         * 不用 width: 100% 是因为在个人习惯调试的时候尽量不麻烦其他元素节点的样式
         * 并且此时任意一个父类也还没有设置 overflow: hidden; 就换了个方法_(:3」∠)...
         */
        
        height: 20px;
        
        background-color: rgba(120, 120, 120, .5); // 随便挑一个常用的灰色做测试
    }
    
    1. 再放进 <div class="selected-container /> 中. 嗯嗯..放这里
    @path serval/script/harusame-template.js
    
    Template.editor = function () {
        // ...
    
        /*
         * before:
         * var $selected_container = SatoriDom.compile(e('div', {'class': 'selected-container'})
         */
        var $selected_container = SatoriDom.compile(e('div', {'class': 'selected-container'}, [
            e('div', {'class': 'selection-part'}),
            e('div', {'class': 'selection-part'}),
            e('div', {'class': 'selection-part'})
        ]))
    
        // ...
    }
    
    
    1. 嗯嗯... 这就是效果. 见图5-7
    图5-7.png
    1. 这里先模拟实际效果. 再把模拟的过程转化为用 js 来控制:
      选区的控制是通过改变 top left rightheight 来实现的.
      见图5-5 中 (这里复制过来了..
    图5-5.png

    先规定一下:

    • 选区中的最上面这行. 比如 1 3 7 行. 之后记为 $selection_top

    • 选区中的中间的部分. 比如 8-9 行. 之后记为 $selection_middle

    • 选区中的最下面这行. 比如 4 10 行. 之后记为 $selection_bottom

    模拟过程比如像这样. 见图5-8:

    图5-8.gif

    可以看到由于样式方面的原因.(可能算是问题).. 计算选区视图大小的时候要额外算上行号所在的空间的宽度. 是 50px.

    绘制视图的整个流程就是:

    mousemove 或者 mouseup 的时候. 比对当前光标的位置 与 选区起点 是否出了偏差 (计算 logicalY)... 如果有就更新选区视图

    1. 计算并存储两点间的 Y 上的差
      var diffY = point_end.logicalY - point_start.logicalY

    2.1. 如果 diffY === 0. 更新
    $selection_top 的 DOM 的 top left

    2.2. 如果 diffY === 1. 更新
    $selection_toptop left &&
    $selection_bottomtop right

    2.3. 如果 diffY > 1. 更新
    $selection_toptop left &&
    $selection_middletop height &&
    $selection_bottomtop right

    JS

    现在测试都基本没问题了. 如果测试内容对实际要做的东西会有干扰. 就考虑删掉哦.. 有以下这个:

    还原为

    然后在 template 中加入这个

    @path serval/script/harusame-template.js
    
    /**
     * 选区片段
     */
    selectionPart: function () {
        return SatoriDom.compile(
            e('div', {'class': 'selection-part'})
        )
    }
    

    接下来把之前想要做的流程转换为代码..

    @path serval/script/harusame-cursor.js
    
    /**
     * 1. 光标本身的元素节点
     * 2. 之前所说的基准点可以做一个初始化.
     */
    var Cursor = function (config) {
        // ...
    
        this.mousedown_point = {}   /* 2 */
    
        this.$selection_top = Template.selectionPart()
        this.$selection_middle = Template.selectionPart()
        this.$selection_bottom = Template.selectionPart()
    
        // ...
    }
    
    /**
     * 更新选区的 值 与 视图
     */
    Cursor.prototype.updateSelection: function () {
        this.findSelection()
        this.updateSelectionView()
    },
    
    /**
     * 更新选区视图
     */
    Cursor.prototype.updateSelectionView: function () {
        var point_start = this.selection_start
        var point_end = this.selection_end
    
        var diffY = point_end.logicalY - point_start.logicalY
    
        switch (diffY) {
    
            case 0:
                this.$selection_top.style.cssText =
                    'top:' + point_start.psysicalY + 'px;' +
                    'right:' + (750 - point_end.psysicalX) + 'px;' +
                    'left:' + (50 + point_start.psysicalX) + 'px;' +
                    'display:' + 'block;'
    
                this.$selection_middle.style.display = 'none'
    
                this.$selection_bottom.style.display = 'none'
    
                break
    
            case 1:
                this.$selection_top.style.cssText =
                    'top:' + point_start.psysicalY + 'px;' +
                    'right:' + 0 + 'px;' +
                    'left:' + (50 + point_start.psysicalX) + 'px;' +
                    'display: block;'
    
                this.$selection_middle.style.display = 'none'
    
                this.$selection_bottom.style.cssText =
                    'top:' + point_end.psysicalY + 'px;' +
                    'right:' + (750 - point_end.psysicalX) + 'px;' +
                    'left:' + 50 + 'px;' +
                    'display: block;'
    
                break
    
            default:
                this.$selection_top.style.cssText =
                    'top:' + point_start.psysicalY + 'px;' +
                    'right:' + 0 + 'px;' +
                    'left:' + (50 + point_start.psysicalX) + 'px;' +
                    'display: block;'
    
                this.$selection_middle.style.cssText =
                    'top:' + (point_start.psysicalY + Line.line_height) + 'px;' +
                    'left:' + 50 + 'px;' +
                    'height:' + (point_end.psysicalY - point_start.psysicalY - Line.line_height) + 'px;' +
                    'display: block;'
    
                this.$selection_bottom.style.cssText =
                    'top:' + point_end.psysicalY + 'px;' +
                    'right:' + (750 - point_end.psysicalX) + 'px;' +
                    'left:' + 50 + 'px;' +
                    'display: block;'
    
                break
        }
    },
    
    

    来看看有没有问题... 见 图5-9.

    图5-9.gif

    嗯... 选区应该没有什么问题.

    说起来示例gif 里的内容都是无意义的数字之类的...因为复制粘贴什么的还没有做..就暂时用这些代替了..

    接下来可能是 复制 剪切 粘贴 Home End ↑ ↓ ← → ...
    感觉内容好多... 其实感觉依旧好水_(:3」∠)...

    在做完这些最基础的功能之后... 重新调整与优化代码.. 之后再做多个光标.. 代码高亮&&智能提示 之类的东西


    CHANGELOG

    2017年8月10日14:14:00
    F 在 getSelectionContent 中 修复了多余的 \n


    上一篇
    #4 从零开始制作在线 代码编辑器

    下一篇
    #6 从零开始制作在线 代码编辑器

    相关文章

      网友评论

        本文标题:#5 从零开始制作在线 代码编辑器

        本文链接:https://www.haomeiwen.com/subject/djrykxtx.html