目录结构和说明
目录初始结构
创建一些目录以及文件,如图1-1 所示。这里只是随手创的,也可以自定目录结构。
目录说明
-
serval/script/harusame-dom.js
提供了创建 DOM 的工具,比createElement(tagName)
那种要稍微方便一点,创建方式借鉴了虚拟DOM。使用方式就在这里说明了,之后也不细说。
// 创建元素节点
var $node = SatoriDom.compile(e('div', {'class': 'demo', 'id': 'demo'}, '这里是文本节点: parent', [
e('div', {'class': 'child'}, [
e('p', '这里是文本节点: child x child')
])
]))
console.log($node.tagName) // DIV
// 其他可能用到的方式
function template_submit (data) {
var $event_node = SatoriDom.compile(e('input', {'class': 'i-have-event', 'type': 'submit'}))
$event_node.addEventListener('click', function (event) {
console.log('You click me:' + data.content)
})
return SatoriDom.compile(e('form', {'class': 'this-is-a-form'}, [
$event_node
]))
}
serval/script/harusame-template.js
存放了一些模板,把与创建DOM 相关的代码抽离出来,避免其在逻辑代码中占用很多篇幅。serval/script/harusame-cursor.js
光标会被单独抽象成一个类。serval/script/harusame-serval.js
绑定整个编辑器的事件,以及逻辑的处理,最重要的部分。serval/style/normalize.scss
见 normalize.scss 官网,这里改了后缀只是方便后缀格式相同....serval/style/harusame-code.scss
是目录serval/style/code/
下所有需要高亮的语言的样式的 入口,这样便于以后做其他语言的样式扩展。这里先有能力解决 js 的语法高亮再想着其他语言吧。serval/style/harusame-serval.scss
描述了编辑器样式。
先直接描绘出成型后的编辑器
对着已有的优秀的编辑器观察,把看到的东西抽象成几部分,再根据这些编写成 DOMs 。
这里对着 Sublime Text 3 截了一张图 1-2:
挺小也挺简单的一张图,但是包含了想做的编辑器的大部分所需 DOMs,或者称他为组件,这里说下明确当前要做的部分:
- 行号 ( 6 ~ 15)
- 光标 ( 第七行最后的白色竖线),与多重光标(7, 9, 15)
- 选中行提示(比如第七行)
- 选中内容提示(比如第九行,第 13 ~ 15 行)
这里插入一点,以下内容会是显而易见的,但是也是编辑器组成中最最重要的,尽量把这些理所当然的也把它梳理出来,毕竟他们也需要 DOM 来显示
- 行的内容(比如第七行的
state = {
) - 编辑器样式(比如这里深灰色,行号的灰色,非关键词的白色)
以下是一些肉眼看不到的
- 行能够接受键盘输入
- 点击一个位置,光标会自动偏移到该行中,离点击字符最近的位置。
- 行号无法选中
至于一些其他的细节:代码高亮,成对的括号提示等,这里会放在以后再做/说。
于是根据这些梳理好的内容,转换成 DOMs:
-
编辑器容器
设置编辑器的背景颜色等,也是存放 其他容器的容器(父节点) -
光标容器
存放 所有光标 的元素节点 -
行容器
存放 一行 的元素节点,包括它的行号,以及行的内容 -
选择容器
存放比如 当前选择行,选择内容 的背景高亮的元素节点 -
键盘输入事件接收器
正如之前所说(好像说了),一个div
本身是不能接受键盘事件的,需要一个接受键盘事件的容器,再将事件的处理逻辑与相应的元素节点绑定。
以上就是需要的 DOMs 了,包含关系也基本没啥问题了:
文件路径 serval/index
<div id="input-container" class="input-container">
<!-- 创建一个 serval 专属的区域,且总是铺满 编辑器的容器 -->
<div class="serval theme-harusame">
<!-- 包裹区域,总是由 行 的高度所撑开 -->
<div class="serval-container">
<!-- 所有 行 的 容器 -->
<div class="line-container">
<div class="line">
<div class="line-number-wrap">
<span class="line-number">1</span>
</div>
<!-- 在pre中,添加使用等宽字体的 buff,使用等宽字体是为了之后,便于文字宽度的计算 -->
<pre class="code-wrap">
<!-- 因为一般放的是代码,codeMirror 是用的 span,试试 code -->
<code class="code-content">const PI = 3.1415</code>
</pre>
</div>
<div class="line">
<div class="line-number-wrap">
<span class="line-number">2</span>
</div>
<pre class="code-wrap">
<code class="code-content">console.info('PI', PI)</code>
</pre>
</div>
<div class="line">
<div class="line-number-wrap">
<span class="line-number">3</span>
</div>
<pre class="code-wrap">
<code class="code-content"></code>
</pre>
</div>
</div>
<!-- 接受键盘事件 的容器 的容器 -->
<div class="inputer-container">
<textarea class="inputer">
<!-- 这里使用一个 看不见的(并不是隐藏哦) textarea 来接受键盘事件 -->
</div>
<!-- 所有 光标 的容器 -->
<div class="cursor-container">
<i class="fake-cursor blink"></i>
</div>
<!-- 选择行 提示 -->
<div class="selected-container">
<div class="selected-line"></div>
</div>
</div>
</div>
</div>
文件路径 serval/style/harusame-serval.css
这里就直接给了编译后的
还是挺暴力的,所有都直接嵌套,防止命名冲突
.serval {
position: relative;
height: 100%;
}
.serval .serval-container {
height: 100%;
}
.serval .serval-container .line-container {
margin-left: 50px;
font-size: 0;
}
.serval .serval-container .line-container .line .line-number-wrap {
position: absolute;
left: 0;
text-align: right;
}
.serval .serval-container .line-container .line .line-number-wrap .line-number {
display: inline-block;
padding-right: 15px;
box-sizing: border-box;
width: 50px;
height: 20px;
line-height: 20px;
font-size: 12px;
}
.serval .serval-container .line-container .line .code-wrap .code-content {
display: block;
height: 20px;
line-height: 20px;
font-size: 12px;
}
.serval .serval-container .cursor-container {
position: absolute;
top: 0;
margin-left: 50px;
}
.serval .serval-container .cursor-container .fake-cursor {
display: block;
position: absolute;
left: 0;
top: 0;
width: 0;
height: 18px;
margin-top: 1px;
}
.serval .serval-container .selected-container {
position: absolute;
top: 0;
width: 100%;
}
.serval .serval-container .selected-container .selected-line {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 20px;
}
.serval .serval-container .inputer-container {
position: absolute;
top: 0;
margin-left: 50px;
}
.serval .serval-container .inputer-container .inputer {
position: absolute;
left: 0;
top: 0;
border: 0;
resize: none;
width: 0;
height: 20px;
line-height: 20px;
opacity: 0;
}
.theme-harusame {
background-color: rgba(0, 0, 0, 0.8);
}
.theme-harusame .serval-container .line-container {
font-family: Consolas;
}
.theme-harusame .serval-container .line-container .line .line-number-wrap .line-number {
color: white;
cursor: default;
}
.theme-harusame .serval-container .line-container .line .code-wrap {
cursor: text;
}
.theme-harusame .serval-container .line-container .line .code-wrap .code-content {
font-family: Consolas;
color: white;
}
.theme-harusame .serval-container .cursor-container .fake-cursor {
border-right: 1px solid rgba(255, 255, 255, 0.9);
}
.theme-harusame .serval-container .selected-container .selected-line {
background-color: rgba(255, 255, 255, 0.15);
}
效果图,见图1-3 ,嗯嗯,不错的感觉。
HTML 方面应该没有什么问题,就把它们转换成为模板,写入 template.js中,并且删掉 index.html 中刚才写的 HTML标签。
文件路径 serval/script/harusame-template.js
;
(function () {
Template = {
/**
* 编辑器
*/
editor: function () {
var $line_container = SatoriDom.compile(e('div', {'class': 'line-container'}))
var $inputer_container = SatoriDom.compile(e('div', {'class': 'inputer-container'}))
var $cursor_container = SatoriDom.compile(e('div', {'class': 'cursor-container'}))
var $selected_container = SatoriDom.compile(e('div', {'class': 'selected-container'}))
var $serval_container = SatoriDom.compile(e('div', {'class': 'serval-container'}, [
$inputer_container,
$selected_container,
$cursor_container,
$line_container
]))
var $fragment = SatoriDom.compile(
e('div', {'class': 'serval theme-harusame'}, [
$serval_container
])
)
return {
$editor: $fragment,
nodes: {
$serval_container: $serval_container,
$line_container: $line_container,
$inputer_container: $inputer_container,
$cursor_container: $cursor_container,
$selected_container: $selected_container
}
}
},
/**
* 行
* @param line_number {string} 行号
* @param initial_content {string} 该行初始内容
*/
line: function (params) {
console.info('params', params)
return SatoriDom.compile(
e('div', {'class': 'line'}, [
e('div', {'class': 'line-number-wrap'}, [
e('span', {'class': 'line-number'}, params.line_number)
]),
e('div', {'class': 'code-wrap'}, [
e('code', {'class': 'code-content'}, params.initial_content || '')
])
])
)
},
/**
* 当前选择行
*/
selectedLine: function () {
return SatoriDom.compile(
e('div', {'class': 'selected-line'})
)
},
/**
* 光标
*/
cursor: function () {
return SatoriDom.compile(
e('i', {'class': 'fake-cursor blink'})
)
},
/**
* 键盘事件接收器
*/
inputer: function () {
return SatoriDom.compile(
e('textarea', {'class': 'inputer'})
)
}
}
window.Template = Template
})()
文件路径 serval/script/cursor.js
;
(function () {
var Cursor = function () {
this.$ref = null
this._generateCursor()
}
Cursor.prototype = {
constructor: Cursor,
/**
* 创建一个游标对象
*/
_generateCursor: function () {
this.$ref = SatoriDom.compile(
e('i', {'class': 'fake-cursor'})
)
}
}
window.Cursor = Cursor
})()
文件路径 serval/script/serval.js
;
(function () {
/**
* 1. 存放所有的存在游标
*/
var Serval = function (config) {
/* 1 */
this.cursor_list = []
this._generateEditor(config)
this._generateInputer()
this._generateCursor()
this._generateSelectedLine()
this._generateLine() // 测试用!!!
}
Serval.prototype = {
constructor: Serval,
/**
* 生成编辑器的主要 DOM结构
*/
_generateEditor: function (config) {
var temp = Template.editor()
var nodes = temp.nodes
this.$serval_container = nodes.$serval_container
this.$line_container = nodes.$line_container
this.$inputer_container = nodes.$inputer_container
this.$cursor_container = nodes.$cursor_container
this.$selected_container = nodes.$selected_container
config['editor-container'].appendChild(temp.$editor)
},
/**
* 生成键盘事件接收器,并渲染
*/
_generateInputer: function () {
var $inputer = Template.inputer()
this.$inputer = $inputer
this.$inputer_container.appendChild($inputer)
},
/**
* 生成一个光标,并渲染
* 1. 创建 cursor 实例
* 2. 持久化该 cursor 实例
* 3. 得到该 cursor 实例的元素节点,渲染到 光标容器 中
*/
_generateCursor: function () {
var cursor = new Cursor() /* 1 */
this.cursor_list.push(cursor) /* 1 */
this.$cursor_container.appendChild(cursor.$ref) /* 3 */
},
/**
* 生成一行,并渲染
*/
_generateLine: function () {
var $line = Template.line({line_number: '1', initial_content: '初始化内容'})
this.$line_container.appendChild($line)
},
/**
* 生成当前选择行的背景颜色提示,并渲染
*/
_generateSelectedLine: function () {
var $selected_line = Template.selectedLine()
this.$selected_line = $selected_line
this.$selected_container.appendChild($selected_line)
}
}
window.Serval = Serval
})()
效果图,见图1-4
嗯嗯,好像没什么毛病...
随手框选了一下,发现,这几个字没法选中,有点不符合预期,在之后做选取内容时,肯定会坑,所以立马填了。
额...因为在当前层叠上下文中,选择行<.selected_container>
的层叠样式最大(他在最下面) 并且 他有position: absolute
的属性,导致覆盖了<.line_container>
,所以
- 去
template.js
中调整一下位置,令他们的覆盖情况更合逻辑。 - 为
<.line_container>
添加一个position: relative
属性 - 另外
2
的副作用就是,由于<.line_container>
具备了定位属性,行的left: 0
,得手工设置为left: -50px
了,不然布局会乱哦
做这些理论上就可以了,当然如果懒得改的话,设置z-index
,pointer-events
之类的也可以,这里就不记了:
文件路径 serval/script/harusame-template.js
改成这样 ↓
var $fragment = SatoriDom.compile(
e('div', {'class': 'serval theme-harusame'}, [
e('div', {'class': 'serval-container'}, [
$inputer_container,
$selected_container,
$cursor_container,
$line_container
])
])
)
文件路径 serval/style/harusame-serval.scss
.line-container {
// 添加一条
position: relative;
}
.line-number-wrap {
// 添加一条
left: -50px;
// 这里的 50px 取决于 .line-number 的 width 属性
}
光标
先说说光标的逻辑。
可以在 #0 节的例子中了解到
// ...
... substring(0, logicalX) ...
// ...
编辑器实际上通过 字符的位置 来添加,删除文本/代码,这里所说的字符的位置,我自作主张把它称为 逻辑位置,对应到 Cursor实例
中,给他命名为 logical
,相应地,也有 物理位置(视图位置),对应到 Cursor实例 的DOM
中,给他命名为 psysical
。
logicalY
决定了光标在编辑器中的 行号
psysicalY
决定了光标 DOM 的 top
值
logicalX
决定了光标在编辑器中的 列号,或者说 第 logicalX 个字符
psysicalX
决定了光标 DOM 的 left
值
除了这些,光标还需要一个保存选区的属性,这里命名为selection
,
selection
是一个对象,它包含一个起始点坐标start
,与一个终点坐标end
举个栗子,对于图 1-5:
为了计数方便,删除了部分缩进,以及这里的缩进都是 4个空格。
有以下光标:
- 第三行的光标
cursor_1
- 第四行的光标
cursor_2
- 第五行的光标
cursor_3
- 第六行的光标
cursor_4
它们分别有以下属性:
(如果看懂了文字说明,以下代码就随便看看)
cursor_1 = {
logicalY: 2, /* 从零开始计算 */
psysicalY: 40, /* 约定一行的高度为 20px */
logicalX: 2, /* 从零开始计算 */
psysicalX: 14,
/* 字符'l',字符'e'的宽度,通过标尺类工具可以得到一个字符(英文)的宽度约等于 7px
* 手工写死的字符宽度面对不同的情况或者说样式上变化,会比较难维护
* 接下来马上会说怎么使用浏览器的计算,虽然也不怎么优雅......_(:3」∠)...
* /
selection: null
}
cursor_2 = {
logicalY: 3,
psysicalY: 60,
logicalX: 0,
psysicalX: 0,
selection: null
}
cursor_3 = {
logicalY: 4,
psysicalY: 80,
logicalX: 5,
psysicalX: 35, // 5 * 7
selection = {
start: {
logicalY: 4,
psysicalY: 80,
logicalX: 0,
psysicalX: 0
},
/* end: {}
* 写到这里的时候发现,end 的位置总是与当前位置相同,于是决定不要这个冗余的部分,
* 虽然与 start 相对应的 end 缺失了,可能会 '引起不适',
* 但是暂时觉得没必要多出一份冗余数据
* 所以这里改变以下 selection的写法 ↓,把 selection 删了,只留下 selection_start
*/
}
selection_start = {
logicalY: 4,
psysicalY: 80,
logicalX: 0,
psysicalX: 0
}
}
cursor_4 = {
logicalY: 5,
psysicalY: 100,
logicalX: 4,
/*
* 刚好四个空格,观察仔细会发现,图1-5 的距离不会是 4格,
* 这里因为偷懒用了 tab进行缩进,导致如此。
* 不要在意细节..._(:3」∠)...啊,不对,编程还是得很注意细节的
*/
psysicalX: 28, /* 空格也约为 7px */
selection_start = {
logicalY: 5,
psysicalY: 100,
logicalX: 4,
psysicalX: 28
}
}
如果看完了甚至没看上面的代码,都可以 显然 得到:
psysicalY = line_height * logicalY
logicalY = psysicalY / line_height
$current_line = document.querySelector(SIGN_LINE + logicalY)
psysicalX = single_byte_length * 7 + double_byte_length * 12
logicalX = single_byte_length + double_byte_length
这里的
line_height
,不是指css
中的line-height
哦,是指 该行的高度-
这里的
SIGN_LINE
,是一个自己约定的名字,这里倾向于使用id
,而不是class
,个人觉得理由如下:1.每行都是唯一的
2.性能稍微高一点
3.可以利用锚点
方便地跳转到该行位置
比如 github
中的代码编辑器(也可以看 codeMirror
)
-
single_byte_length
这个变量名字译为单字节字符的长度(数量),同理double_byte_length
变量名字译为双字节字符的长度(数量)。
(这个名字我不清楚对不对,之后再查阅下文献。
为什么这样分呢?
因为在计算光标的psysicalX
时,需要计算字符的 宽度,所以根据宽度来分。
在 font-size: 12px
的前提下,
单字节字符的宽度,比如数字,英文字符,半角标点符号
a b c 1 2 3 . , /
等等 都约为 7px
双字节字符的宽度,比如汉字,日语,全角标点符号
你 好 啊 こ は お ア 。,、
等等 都约为 12px
说明就到这里。
之所以把这些类似公式一样的东西列出来,就是想说它们之间总是一一对应,改变其中一个,另外一个也相应地改变。这个情况,是不是有点类似于双向绑定
的概念呢。提到双向绑定,在如今,前端大佬们有个比较好的处理方法就是使用Object.defineProperty
哦。
接下来终于可以开始写代码。
计算光标位置
首先 先去serval/script/harusame-serval.js
中提供事件
文件位置 serval/script/harusame-serval.js
为了能显示更多的有效内容,先把写过的代码在这里删掉了。
;
(function () {
var Serval = function (config) {
this._bindMouseEvent()
}
Serval.prototype = {
/**
* 绑定各种鼠标事件
*/
_bindMouseEvent: function () {
var self = this
/**
* addEventListener 是指自己写的方法,见最下面
* mousedown 时,就对光标位置进行计算
*/
addEventListener(self.$serval_container, 'mousedown', function (event) {
console.log(event) // 先看看 event 中有什么好用的属性。
})
},
}
/**
* 可能会对 addEventListener 进行一些兼容处理
* 实际上并没有处理,不过也好,至少留一条后路...
*/
function addEventListener (v_el, v_type, v_callback) {
v_el.addEventListener(v_type, v_callback)
}
window.Serval = Serval
})()
先看看 event 中有什么好用的属性。
这里说一下由于一些事件在不同的浏览器行为是不同的,如果要兼容某个版本的浏览器,所以最好调试的时候看看预先各个浏览器的事件行为。这里在 Firefox 以及 Google 是没什么差异的,所以就不截图了。
这里可以看到有二个 layer
前缀的属性,它总是能得到 相对于 event.target
元素的 点击位置。这个特性就很好用!
于是决定使用 event.layerY
和 event.layerX
来进行光标位置的计算!
这里要插一句_(:3」∠)...,在 demo 中 Y的位置 是使用 event.target.id.split[SIGN][1]
来获得的,但是在以后的开发中,记得会出现一些麻烦的判断,所以这里用 layerY
尝试一下。嘛,各有利弊,layerY
的话,也可能得考虑padding margin height line-height
等 css
造成的计算偏差。
位置: serval/script/harusame-serval.js
为了突出主要说明部分,删掉了其他代码
;
(function () {
var Serval = function (config) {
this._bindMouseEvent()
}
Serval.prototype = {
constructor: Serval,
/**
* 绑定各种鼠标事件
*/
_bindMouseEvent: function () {
var self = this
/**
* addEventListener 是指自己写的方法,见最下面
* 当 mousedown 时,就对光标位置进行计算
*/
addEventListener(self.$serval_container, 'mousedown', function (event) {
self.allocTask(function (v_cursor) {
v_cursor.psysicalY = event.layerY
v_cursor.psysicalX = event.layerX
})
})
},
/**
* 为所有光标分配任务
* 对光标操作时,统一通过这个接口
* 这里约定必须传入一个回调函数,所以不使用 v_task && v_task() 进行判断
*/
allocTask: function (v_task) {
var self = this
for (var i = 0, len = self.cursor_list.length; i < len; i++) {
v_task(self.cursor_list[i])
}
},
}
})()
做好了入口,接下来去 serval/script/harusame-cursor.js
写逻辑哦。
先写logicalY
与 psysicalY
部分
文件位置 serval/script/harusame-cursor.js
;
(function () {
/**
* 这里先约(写)定(死) 行高
*/
var LINE_HEIGHT = 20
/**
* 1. 光标本身的元素节点
* 2. 光标所在行的元素节点
*/
var Cursor = function (config) {
this.$ref = null /* 1 */
this.$line = null /* 2 */
this._logicalY = 0
this._logicalX = 0
this._psysicalY = 0
this._psysicalX = 0
this.selection_start = null
this._generateCursor()
this._setObserver()
}
Cursor.prototype = {
constructor: Cursor,
/**
* 创建一个游标对象
*/
_generateCursor: function () {
this.$ref = SatoriDom.compile(
e('i', {'class': 'fake-cursor'})
)
},
/**
* 绑定 逻辑位置 与 物理位置 之间的关系
*/
_setObserver: function () {
/**
* 这里的 self 由于也是 js关键字,所以会高亮
* self 原本指向 window,一般用不到
*/
var self = this
/**
* 1. 这里赋值的是 _logicalY 哦,下面也是
* 2. 更新 psysicalY 的值
* 3. 更新 DOM 位置
* 4. 写到这里发现有点问题......
*/
Object.defineProperty(self, 'logicalY', {
set: function (v_logicalY) {
self._logicalY = v_logicalY /* 1 */
self._psysicalY = self.calcPsysicalY(v_logicalY) /* 2 */
self.setY(self._psysicalY) /* 3 */
self.$line = document.getElementById(LINE) /* 4 */
},
get: function () {
return self._logicalY
}
})
Object.defineProperty(self, 'psysicalY', {
set: function (v_psysicalY) {
self.logicalY = self.calcLogicalY(v_psysicalY)
},
get: function () {
return self._psysicalY
}
})
},
setY: function (v_psysicalY) {
this.$ref.style.top = v_psysicalY + 'px'
},
/**
* 计算 物理 Y
*/
calcPsysicalY: function (v_logicalY) {
return v_logicalY * LINE_HEIGHT
},
/**
* 计算 逻辑 Y
*/
calcLogicalY: function (v_psysicalY) {
return parseInt(v_psysicalY / LINE_HEIGHT)
}
}
window.Cursor = Cursor
})()
这里可以看看效果,截图软件截不到鼠标_(:3」∠)...。
但是也注意到了设计上的问题:在self.logicalY
中,需要用到 $line
,这个东西不应该分在Cursor
中,毕竟他不属于光标,以及LINE_HEIGHT
属性,也不属于。甚至在接下来的 self.logicalX
中,也需要用到 $line
,这个时候,不如把与 行 有关的,再单独抽象成一个类会比较合适。