总体实现思路
Webpack配置
因为我这里主要使用TypeScript去撸的代码,所以我们首先是由webpack来构建一下我们项目的基本结构。 从思维导图中我们可以看到webpack的配置分为两种模式,开发环境和线上环境,在这里可以通过书写两个文件来实现不同的配置方式,然后在package.json的script脚本中使用 --config ./webpack.xxx.config.js来指定各个命令使用的webpack配置。 因为这些都是webpack的基本配置,所以不做赘述了,具体的配置可以在仓库里查看。
Tips:
- 这里记录一个webpack-cli和webpack-dev-server因版本不兼容导致的报错
解决方式也很简单,只需要将webpack-cli的版本降到3.x即可,下面是我在项目中的依赖版本
确定使用方式
所谓万事开头难,所以第一步我认为应该是确定该组件的使用方式,通过使用方式去明确我们的项目结构和文件入口的书写方式, 这里主要采用的是wangEditor使用CDN引入的方式:
const E = window.Editor;
const editor = new E('#container');
editor.create()
主要实现
在我们开始主要的代码实现之前,我们需要增加一下我们的知识储备,方便我们进行后续逻辑的书写。 首先我们来思考几个问题:
- 怎么可以让一个元素变成一个可编辑文字的区域?
- 怎么可以获取选中的文字?
- 怎么对选中的文字来进行加粗?改变颜色?设置标题?
上面这三个问题,是我刚开始撸代码时脑子里第一时间浮现出来的,接着我们去搜集相关资料解决了这三个问题,那么实现一个简单的富文本编辑器就不会这么难了。
接下来我们来一个个的解决上面提出的三个问题: 其实上面的这三个问题,浏览器都帮我们解决了,都有对应的API可以帮助我们实现。可能有些API已经被废弃,但是浏览器还是保留了它的使用方式,这可能也正是很多编辑器要实现自己的编辑引擎的原因吧。
怎么可以让一个元素变成一个可编辑文字的区域?
contenteditable="true"- 对于一个元素想要变成一个可编辑的区域,只需要设置这个属性即可。 具体的介绍,可以查看MDN
怎么可以获取到选中的文字?
这个问题其实本质就是获取到光标的操作。这里浏览器也给我们暴露了对应的API方便我们操作
Selection对象
Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。要获取用于检查或修改的 Selection 对象,请调用 window.getSelection()。
对于Selection对象我们需要用到的就是isCollapsed属性:
isCollapsed: true: 表示没有选中文字,即无拖蓝isCollapsed: false: 表示选中文字,有拖蓝
但是我们一般情况下不会直接操作该对象,而是去操作range对象
Range对象
Selection 对象所对应的是用户所选择的 ranges (区域),俗称拖蓝。默认情况下,该函数只针对一个区域,我们可以这样使用这个函数:
const selObj = window.getSelection();
const range = selObj.getRangeAt(0);// 参数表示的是第N个区域
我们通过两张图来说明一下range对象的参数:
- collapsed: 返回一个用于判断 Range 起始位置和终止位置是否相同的布尔值。
- commonAncestorContainer: 返回包含 startContainer 和 endContainer 的最深的节点。
- endContainer: range范围的结束节点
- startContainer: range范围的起始节点。
- startOffset: range区域起点位置的偏移量。
- endOffset: range区域终点位置的偏移量。
怎么对选中的文字来进行加粗?改变颜色?设置标题?
当我们对元素设置了contenteditable="true"属性之后,便可以利用document.execCommand()来对已激活的编辑区域进行操作。
该方法有三个参数:
- aCommandName:一个 DOMString ,命令的名称(具体命令列表,可以查看MDN官网)
- aShowDefaultUI:一个 Boolean, 是否展示用户界面,一般为 false
- aValueArgument:一些命令所需要额外的参数,默认为null。
使用方式
document.execCommand('bold', false, undefined) // 使range区域文字加粗,不需要额外参数,所以第三个参数可以不传
document.execCommand('fontSize', false, 7) // 改变range区域文字大小,第三个参数为指定的大小(1-7),7是字体最大,3是默认
ok,至此我们有了足够的知识储备就可以放心的去实现我们的编辑器了。
实现过程
Editor
我们首先来完成编辑器主区域的功能。(这里只展现关键代码,详细的代码可以前往仓库查看)
对于该构造函数,我们根据使用方式,可以确定需要一个cteate方法去构建编辑器。
/** 用户传入的根节点 */
public wrapperElem: HTMLElement | null
/** 菜单元素 */
public menuElem: Menu
/** 作为编辑区域的元素 */
public editorElement: HTMLElement | null
constructor(selector) {
this.wrapperElem = selectElem(selector, 'content-wrapper') // selectElem这是我自己写的选中元素的方法
this.menuElem = new Menu(this)
this.editorElement = createElem('div', 'editor-wrapper') // createElem同上,也是自己写的创建新元素的方法
}
/** 生成编辑器 */
public create(): void {
// 初始化菜单栏
this.menuElem.init()
// 设置选中dom元素为可编辑区域
this.editorElement?.setAttribute('contenteditable', 'true')
// fix: 加入该元素的目的是在用户输入时使其被p标签包裹
this.createPlaceholder()
// 绑定事件
this.bindEvent()
// 将编辑器元素添加进父元素中
this.wrapperElem?.appendChild(this.editorElement!)
}
Tips: 这里需要注意一下,因为在引入方式里是传入了一个选择器,我是将这个选择器作为了整个编辑器的父元素,而不是直接将这个元素设置成可编辑。这样做的好处就是可以将生成的菜单元素和主编辑区域在该父元素内进行布局。
这里还有一点需要注意的,设置了contenteditable="true"属性之后,我们在编辑区输入文字时,浏览器默认是将文字直接放入当前可编辑的元素中的,当换行之后是默认使用div元素,所以我们需要在这里修改一下浏览器的默认行为
/**
* 使用户的输入处于p标签包裹内
*/
private createPlaceholder(): void {
const pElem = createElem('p')
// 这里需要注意p标签内需要用br标签进行占位
pElem.innerHTML = `<br>`
this.editorElement?.appendChild(pElem)
}
这样我们在输入的时候就是默认在p标签内进行的,br是占位符,如果没有该元素,那第一次输入时是不会在p标签中的。
Tips: 这里我们还需要注意的是,如果用户首次在没输入任何文字的情况下直接点击删除键,那么我们的占位p标签就会被删除掉,所以我们这里需要添加一个事件监听,去捕获用户的按键操作
/** 键盘弹起事件 */
document.addEventListener('keyup', (e: KeyboardEvent) => {
// 删除按钮
if (e.keyCode === 8) {
// 防止用户上来就按删除键,造成的文本无法被p标签包裹的问题
if (!this.editorElement?.innerHTML) {
this.createPlaceholder()
}
}
}
至此就完成了编辑器的输入部分,下面我们就来考虑编辑器中文字的选中。
这里也是通过监听事件去捕获用户选中的文字,这里采用的是鼠标抬起的事件监听
// 缓存选中文字
export let cachedRange: Range | null | undefined = null
/** 鼠标弹起事件 */
document.addEventListener('mouseup', () => {
const selection = window.getSelection()
// isCollapsed为false时证明选中了文字
if (!selection?.isCollapsed) {
cachedRange = selection?.getRangeAt(0)
}
})
这里需要对range进行缓存,因为我们选中文字之后去点击菜单栏的某一项时,会重新获取range范围,导致我们之前选中的文字失效。这样就不能正确设置菜单的功能了。
到这一步之后就完成了编辑器区域的主要功能,接下来是Menu菜单来的功能。
Menu
首先Menu菜单需要一个菜单的列表,即我们所需要展示出来的功能,这里我是单独新建了一个文件去管理该列表
export interface MenuBtnListProps {
/** 按钮对应的命令 */
command?: string
/** 按钮展示的icon */
icon: string
/** 按钮的子级,例如设置h1/h2/h3的标题 */
children?: MenuBtnListProps[]
}
// 菜单项的按钮
export const menuList: MenuBtnListProps[] = [
{
icon: 'heading',
children: [
{ icon: 'h-1', command: 'fontSize-6' },
{ icon: 'h-2', command: 'fontSize-5' },
{ icon: 'h-3', command: 'fontSize-4' },
{ icon: 'h-4', command: 'fontSize-3' },
{ icon: 'h-5', command: 'fontSize-2' },
{ icon: 'h-6', command: 'fontSize-1' },
],
},
{
command: 'bold',
icon: 'bold',
},
{
icon: 'font-color',
children: [
{ icon: 'font-color', command: 'foreColor-black' },
{ icon: 'red', command: 'foreColor-red' },
{ icon: 'yellow', command: 'foreColor-yellow' },
{ icon: 'blue', command: 'foreColor-blue' },
{ icon: 'green', command: 'foreColor-green' },
],
},
]
这里我采用的icon是阿里云在线图标生成的font,使用方式也很简单,只要在html中引入在线css(当然也可以下载到本地)
<!-- 引入在线图标 -->
<link rel="stylesheet" href="//at.alicdn.com/t/xxxxxxx.css">
然后设置对应的类名即可
<!-- 引入在线图标 -->
<span class="iconfont icon-heading"></span>
接下来就是初始化菜单了
public init(): void {
const ulElem = createElem('ul')
ulElem.classList.add('menu-wrapper')
this.btnList?.forEach(_item => {
const liElem = createElem('li', `menu-item iconfont icon-${_item.icon}`)
liElem.setAttribute('flag', _item.flag!)
// 利用事件委托绑定事件
this.bindEvent(liElem)
// 添加自定义属性
if (_item?.command) {
liElem.setAttribute('command', _item?.command)
}
// 如果有子级
if (_item.children) {
const pElem = createElem('p', 'menu-item-child')
// 遍历子元素生成对应节点
_item.children.forEach((_child: MenuBtnListProps) => {
const spanElem = createElem('span', `iconfont icon-${_child.icon}`)
pElem.appendChild(spanElem)
// 给子级添加对应的属性
if (_child?.command) {
spanElem.setAttribute('command', _child?.command)
}
liElem.appendChild(pElem)
})
}
ulElem.appendChild(liElem)
})
this.editor.wrapperElem!.appendChild(ulElem)
}
/**
* 绑定点击菜单栏的事件
*/
public bindEvent(element: HTMLElement): void {
element.addEventListener('click', e => {
const clickElem = e.target as HTMLElement
if (!cachedRange) {
// 没有选中元素的情况下,就新建range,使之后输入的文字应用新的规则
// 需要注意的是这里需要把光标移动到末尾
this.editor.editorElement?.focus()
const range = document.createRange()
range.selectNodeContents(this.editor.editorElement!)
range.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
} else {
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(cachedRange!)
}
this.handleCommand(clickElem)
})
}
/**
* 处理各个菜单项命令
*/
public handleCommand(target: HTMLElement): void {
// command_KV : ["foreColor", "red"]
const command_KV: string[] = target?.getAttribute('command')?.split('-')!
command_KV?.length && document.execCommand(command_KV[0], false, command_KV[1] || undefined)
}
初始化时就是对我们的MenuBtnList进行遍历创建对应的元素,然后设置相关属性即可。这里需要注意的是我们将每个按钮对应的命令设置为了自定义属性,这样我们在点击对应按钮的时候就可以获取该属性然后执行对应的命令
在handleCommand方法中就是将自定义属性command解析出来然后执行document.execCommand方法去执行命令
Tips: 在菜单绑定事件的方法中有一点需要注意,我在这里判断了
cachedRange当前是否存在缓存的情况,因为考虑到有这种情况: 用户在没选 中文字的情况下,点击了菜单中的按钮,这时应该是之后输入的文字应用该按钮对应的功能,所以在这里新建了一个range来完成这个功能。 根据以上代码我们可以看出在点击了菜单按钮后应该将焦点设置在编辑区域内,然后创建对应的range,这里如果不添加新的range,光标聚焦后会始终在最前面,所以我们这里实际是将光标移动的最末尾的位置。
到这里就完成了整个编辑器的代码啦。我这里只做了简单的叙述,将书写过程中重要的代码和碰到的问题做了梳理。可能其中还有问题没有考虑到希望大家可以提出~
实际效果:
(请忽略我浏览器插件导致的绿色背景(^▽^))
总结
- 在这整个过程中学习了之前几乎都没有听说过的API,比如range、selection、contenteditable这些,实打实的宽展了知识面
- 再者就是在这个过程中体验了一把从0到1的过程,因为可能在日常的工作中都是采用的现有的框架去做的,很少会去配置webpack这些东西,这次也是复习了一下之前工程化相关的内容吧~
- 了解了一个富文本编辑器的基本流程,这应该是最重要的。因为之前在使用富文本编辑器时只是一味的在使用,并没有说去深究它其中的一些原理,这次深深的体会到了富文本编辑器的一部分工作流程