一、前言
这个学期闲来无事,想自己实现一个博客,前期搭建还是挺流畅的,学到一些新的东西,还是很开心。做博客肯定绕不开富文本编辑的问题,当时也了解了一下现有的富文本技术,document.execCommand 方法已经将要被废弃,我也在想能不能自己实现一个富文本编辑器。在四处调研的情况下,我发现了vue中的defineCustomElement方法,它能够将一个vue组件打包成一个自定义元素,我就想能不能在实现基础的富文本修改选中部分样式的基础上结合vue组件来实现更多的组件,方便用户更多样化的展示,所以对在vue中实现富文本做了一下技术调研,发现写组件的时候很爽,但是坑点也很多。这篇文章也是介绍一下对defineCustomElement实现富文本编辑器的一些技术探究,以及坑点如何埋坑 (不一定能全埋上,至少能看得过去...)。这有可能是个系列文章,主要是坑点太多了,下面我将简要说一下每一篇文章将谈到哪些点:
- 实现富文本编辑器中基础的选中修改样式功能
- 实现简单vue组件编写,并如何将他运用到富文本中去
- 探究如何给编写好的组件加上一些统一的样式或者逻辑(如:给每个组件都加上一个关闭按钮)
- 处理焦点问题,如何处理套娃contenteditable元素编辑内容
- 实现保存已编辑的内容,并能够根据保存内容复现当前页面
- 解决自定义元素组件引用普通的vue组件样式丢失问题
- 解决自定义元素组件引用第三方组件库导致样式丢失问题(以antdv为例)
以上就是每篇文章大致要解决的点。顺便说一下,这是本人第一次写文章记录,难免有所不足,望读者能够海涵;同时如果有什么技术上的意见和建议欢迎在评论区留言交流,感谢!
二、实现简易的能更改样式的富文本
1. 基础框架
我初步打算是做像知乎一样直接编辑内容的富文本,觉得这样子对读者友善一点,所见即所得;这就绕不开contenteditable这个属性。先展示初始代码:
js
// RichText.ts
class RichText {
private rootElement!: HTMLElement
private mode!: 'edit' | 'show'
private focusEl: HTMLElement | null = null
constructor(
parent: HTMLElement,
mode: 'edit' | 'show' = 'edit'
) {
this.initRootNode(parent, mode)
}
public getRootElement() {
return this.rootElement
}
private initRootNode(parent: HTMLElement, mode: 'edit' | 'show' = 'edit') {
this.rootElement = document.createElement('div')
this.rootElement.contentEditable = mode === 'edit' ? 'true' : 'false'
parent.appendChild(this.rootElement)
}
}
这样就能实现最基础的富文本编辑了,它接受一个父节点和模式,将rootElement挂载到父节点上,根据mode是否为edit来决定是否启用编辑状态。
2. 实现选中态样式改变
接下来是第一个难点,那就是选中样式改变。之前我们可以用document.execCommand来做,但是现在这个方法即将废弃,新的方法还一直出不来,这一块就需要我们自己来做了。做这件事主要有几个要点:
- 获取到当前用户选中的元素,并判断该元素是否在编辑区域内
- 如果是文本节点内的文字样式改变,则只需要新建一个span/text节点,将选中的内容放入,改变span/text系欸但的样式为设置的样式即可
- 如果是多行选中,或者是跨element节点进行选中,则要进行以下操作:遍历所有选中的文本节点,并将它们与选中的range进行比对,观察他属于选中区域的开头、中间还是结尾;如果是开头或者结尾则节点需要分裂,如果是中间则直接将样式覆盖上去
- 在做任何修改前都要检查一遍传递的样式是不是在源节点上本来就存在的,如果存在就不执行上述操作直接返回,这将减少大量的无意义节点的产生
window.getSelection这个方法就不过多介绍了,有兴趣可以移步mdn查看window.getSelection, 下面是具体的代码,详情看注释:
js
class RichText {
private rootElement!: HTMLElement
private mode!: 'edit' | 'show'
private focusEl: HTMLElement | null = null
constructor(
parent: HTMLElement,
mode: 'edit' | 'show' = 'edit'
) {
this.initRootNode(parent, mode)
}
public getRootElement() {
return this.rootElement
}
private initRootNode(parent: HTMLElement, mode: 'edit' | 'show' = 'edit') {
this.rootElement = document.createElement('div')
this.rootElement.contentEditable = mode === 'edit' ? 'true' : 'false'
parent.appendChild(this.rootElement)
}
// 这就是向外暴露的修改样式的方法
public getCustomSelection(styles: CSSProperties) {
// 获取range
const range = this.getCursorRange()
if (range) {
// 获取到选中区域的公共父级
const { commonAncestorContainer } = range
// 根据节点类型判断调用哪个处理的方法
if (commonAncestorContainer.nodeType === Node.TEXT_NODE) {
this.processingTextNode(range, styles)
} else {
this.processingHTMLNode(range, styles)
}
}
}
// 获取当前光标位置
private getCursorRange() {
const sel = getSelection()
if (sel && sel.rangeCount) {
const range = sel.getRangeAt(0)
// 只允许rootElement的子节点参与修改,不允许本身参与修改
if (this.rootElement.contains(range.commonAncestorContainer)) return range
}
return null
}
// 如果是文本节点则需要分裂
private processingTextNode(range: Range, styles: CSSProperties) {
const { startContainer, endContainer, startOffset, endOffset, commonAncestorContainer } = range
// 判断样式是不是设置过了,设置了就不需要继续操作了
if (this.checkElementStyles(commonAncestorContainer.parentElement!, styles)) return
// 如果选中的文本为父级的所有文本,则直接修改父级的样式就好,不需要分裂节点
if (range.cloneContents().textContent === commonAncestorContainer.textContent) {
this.applyCSSToObject(commonAncestorContainer.parentElement!, styles)
return
}
// 这里代表选中内容为一个文本节点的一部分,这代表要分裂节点了
// 首先将选中文本之前的文本用一个新的文本节点装起来
const startRange = new Range()
startRange.setStartBefore(commonAncestorContainer)
startRange.setEnd(startContainer, startOffset)
const startEl = document.createTextNode(startRange.cloneContents().textContent || '')
startRange.detach()
// 然后将选中文本之后的文本用一个新的文本节点装起来
const endRange = new Range()
endRange.setStart(endContainer, endOffset)
endRange.setEndAfter(commonAncestorContainer)
const endEl = document.createTextNode(endRange.cloneContents().textContent || '')
endRange.detach()
// 将选中的文本用一个span标签装起来,并将样式放在span标签上
const targetEl = document.createElement('span')
const textNode = document.createTextNode(range.cloneContents().textContent || '')
targetEl.appendChild(textNode)
this.applyCSSToObject(targetEl, styles)
range.detach()
// 按照顺序将节点插入到公共文本节点的父节点中,然后将公共文本节点删除掉
commonAncestorContainer.parentNode?.insertBefore(startEl, commonAncestorContainer)
commonAncestorContainer.parentNode?.insertBefore(targetEl, commonAncestorContainer)
commonAncestorContainer.parentNode?.insertBefore(endEl, commonAncestorContainer)
commonAncestorContainer.textContent = ''
commonAncestorContainer.parentNode?.removeChild(commonAncestorContainer)
}
// 如果是元素节点则需要找到所有文本节点,并按照分区进行截断设置或者全部设置
private processingHTMLNode(range: Range, styles: CSSProperties) {
const { startContainer, endContainer, startOffset, endOffset, commonAncestorContainer } = range
// 这个函数是不断地找公共父元素中,所有与选中文本相交的文本节点,
const textNodesUnder = (root: Node) => {
const textNodes: Node[] = []
const addTextNodes = (el: Node) => {
if (el.nodeType === Node.TEXT_NODE && range.intersectsNode(el)) {
textNodes.push(el)
} else {
for (let i = 0, len = el.childNodes.length; i < len; ++i) {
addTextNodes(el.childNodes[i])
}
}
}
addTextNodes(root)
return textNodes
}
const textNodes = textNodesUnder(commonAncestorContainer)
// 接下来只需要处理与选中区域相交的最底层节点就好了
textNodes.forEach((node) => {
// 同样的先判断是不是样式一样的,一样的就不继续处理了
if (node.parentElement && this.checkElementStyles(node.parentElement, styles)) return
if (node === startContainer) {
// 如果是开头的文本节点,则要分裂成左右两部分,右侧要装在span设置为新样式
console.log('start---->', startContainer)
const [lText, rText] = this.splitStr(startContainer.textContent || '', startOffset)
const lTextNode = document.createTextNode(lText)
const rTextNode = document.createTextNode(rText)
const rTextEl = document.createElement('span')
this.applyCSSToObject(rTextEl, styles)
rTextEl.appendChild(rTextNode)
node.parentNode?.insertBefore(lTextNode, node)
node.parentNode?.replaceChild(rTextEl, node)
} else if (node === endContainer) {
// 如果是结尾的文本节点,则要分裂成左右两部分,左侧要装在span设置为新样式
console.log('end---->', endContainer)
const [lText, rText] = this.splitStr(endContainer.textContent || '', endOffset)
const lTextNode = document.createTextNode(lText)
const rTextNode = document.createTextNode(rText)
const lTextEl = document.createElement('span')
this.applyCSSToObject(lTextEl, styles)
lTextEl.appendChild(lTextNode)
node.parentNode?.insertBefore(lTextEl, node)
node.parentNode?.replaceChild(rTextNode, node)
} else {
// 如果是中间节点,则直接全部装起来,设置样式
console.log('center-->', node)
const text = node.textContent
const textNode = document.createTextNode(text || '')
const textEl = document.createElement('span')
this.applyCSSToObject(textEl, styles)
textEl.appendChild(textNode)
node.parentNode?.replaceChild(textEl, node)
}
})
}
// 将样式设置到节点
private applyCSSToObject(element: HTMLElement, cssObject: CSSProperties): void {
Object.assign(element.style, cssObject)
}
// 检查样式是否都设置了,如果设置了就不需要重复设置了
private checkElementStyles(element: HTMLElement, cssObject: CSSProperties): boolean {
const elementStyles = window.getComputedStyle(element)
for (const prop in cssObject) {
if (Object.prototype.hasOwnProperty.call(cssObject, prop)) {
if (elementStyles[prop] !== cssObject[prop]) {
return false
}
}
}
return true
}
private splitStr(str: string, offset: number) {
return [str.slice(0, offset), str.slice(offset)]
}
}
这样就能够实现单行文本或者多行文本的样式改变了,下面是运行截图:
三、总结
这篇文章主要概述了一下我们最终的目标是什么,以及简单的展示了一下我是怎么利用window.getSelection方法来实现选中文本样式改变的。下篇文章我将讨论一下如何实现vue自定义元素并运用到富文本中去,有任何问题都可以在评论区反馈哦,感谢!