一、技术点引入
前一篇文章主要大致说明了如何编写一个简易的富文本编辑器,这篇文章主要讲解如何编写vue的自定义元素,以及如何在富文本中使用。下面是几个技术要点:
- vue中的defineCustomElement原理:具体详情请参阅vue官网文章Vue 与 Web Components。简要来说,defineCustomElement方法需要传入一个vue组件,方法会将这个组件解析为一个webcomponent并返回。
- webcomponent原理:具体详情请参阅mdnWeb Component。简要来说,webcomponent有三个组成部分:Custom element(自定义元素) 、Shadow DOM(影子 DOM) 以及 HTML template(HTML 模板) ;他还有个特性,也就是自定义元素内部无论有多么复杂的元素结构,浏览器在处理一些事件的时候也总会将其视为一个基本元素 ,而不会将事件触发往里面找触发的具体元素节点。下面将重点说说自定义元素和shadow dom,这也是最重要的坑点
- Custom element(自定义元素):详情请参阅mdn文档使用自定义元素。简要来说,自定义元素是一个类,如果他是一个新的自定义元素(也就是独立元素),那么他就要继承自HTMLElement,如果他想在某个元素上进行修改,则继承自对应的元素(这一点很重要,我们后续给所有自定义元素添加公共样式和元素时就依据这一点)。所以defineCustomElement 方法它返回的也是一个类 。同时,自定义元素需要被注册,此时要调用customElements.define方法,他接受两个入参,第一个是自定义元素名,第二个是自定义元素这个类;具体示例可以参阅mdn,此处不多赘述。
- Shadow DOM :详情请参阅mdn文档使用影子 DOM。这是使用defineCustomElement最 坑 的点,没有之一,后续很多操作都是为了给shadow dom填坑而存在的。其中最重要的便是 CSS样式隔离 ,他带来的好处就是内部样式不会被外部样式污染,但是在这里它是一个坑点而不是好处。。。这代表vue的样式选择器无法选中自定义元素内部,所以vue在解决这个问题时采用了一个方案:它规定自定义元素的单文件组件必须以 .ce.vue 结尾,这样这个组件在编译时就会将组件内定义的所有style标签编译完成后暴露到组件对象的styles中,方便自定义元素时插入style,比如这样:
二、代码实现
- 首先我们封装一个基础的CustonLink.ce.vue组件:
js
<!-- CustonLink.ce.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps({
href: String,
text: String
})
const hrefRef = ref(props.href || ' ')
const textRef = ref(props.text || props.href || ' ')
const updateText = (e: Event) => {
textRef.value = (e.target as HTMLLinkElement).textContent || ''
}
</script>
<template>
<a contenteditable="true" class="link" :href="hrefRef" target="_blank" @input="updateText">{{
textRef
}}</a>
</template>
<script lang="ts">
// 组件静态属性要在这里定义,这是初始化时能拿到的
export default {
name: 'custom-link',
type: 'inline'
}
</script>
<style lang="less" scoped>
.link {
outline: 0px solid transparent;
}
</style>
它接受两个参数,一个是文本,一个是链接,并且入参只用于初始化数据,并且一定要注意自定义组件名,不然在调用customElements.define方法时拿不到名字。
- 然后,我们写一个index.ts组件,用于注册所有的自定义元素和将这些自定义元素导出:
js
import { defineCustomElement } from 'vue'
import { customComponentsStore } from '@/stores/customComponents'
import CustomLink from './CustomLink.ce.vue'
const Components = [CustomLink]
type CustomComponents = {
[key: string]: {
name: string
type: 'inline' | 'block'
nestable: boolean
props?: {
[key: string]: string
}
Constructor: CustomElementConstructor
}
}
const customComponents = {
install() {
const componentsName: CustomComponents = {}
Components.forEach((component) => {
console.log(component);
const CustomElement = defineCustomElement(component)
const type = component.type ?? 'inline'
customElements.define(component.name ?? component.__name, CustomElement)
const name = component.name ?? component.__name
componentsName[name] = {
name,
type,
nestable: component.nestable ?? false,
Constructor: CustomElement
}
if (component.props) {
const props = {}
Object.keys(component.props).forEach((prop) => {
props[prop] = component.props[prop].name
})
componentsName[name].props = props
}
})
const customComponents = customComponentsStore()
customComponents.setComponents(componentsName)
}
}
export default customComponents
index.ts主要就是将自定义元素全部注册,并将所有的元素相关信息和构造函数都用pinia保存起来,方便我们的RichText类来使用,并将上述过程打包成install方法,方便main.ts直接app.use完成注册。
- RichText.ts也要进行修改:
js
import { type CSSProperties } from './type'
import { customComponentsStore } from '@/stores/customComponents'
// 解决pinia循环引用导致报错的问题
const getCustomComponents = () => {
return customComponentsStore().getComponents
}
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)
}
// 解决shadow dom引发的contenteditable光标丢失
public insertCustomElement(name: string, props = {}) {
if (!getCustomComponents()[name]) {
console.error(`不存在该自定义元素:${name}`)
return
}
// 这种方式新建元素支持不同元素类型,比setAttribute好
const customComponent = getCustomComponents()[name]
const componentInstance = new customComponent.Constructor({ ...props })
const range = this.getCursorRange()
if (range) {
if (!range.collapsed) {
range.deleteContents()
range.collapse()
}
componentInstance.setAttribute('mode', this.mode)
if (customComponent.type === 'inline') {
this.insertInlineCustomElement(componentInstance, range)
} else {
this.insertBlockCustomElement(componentInstance, range)
}
range.collapse(true)
this.rootElement.focus()
}
}
// 插入行内自定义元素
private insertInlineCustomElement(el: HTMLElement, range: Range) {
const container = document.createElement('div')
container.style.display = 'inline-block'
container.setAttribute('contenteditable', 'false')
el.setAttribute('containereditable', 'true')
container.appendChild(el)
range.insertNode(container)
}
// 插入块级自定义元素
private insertBlockCustomElement(el: HTMLElement, range: Range) {
const container = document.createElement('div')
container.contentEditable = 'false'
container.appendChild(el)
const afterContainer = document.createElement('div')
afterContainer.appendChild(document.createElement('br'))
range.insertNode(container)
range.setStartAfter(container)
range.collapse(true)
range.insertNode(afterContainer)
range.setStartAfter(afterContainer)
range.collapse(true)
}
// 这就是向外暴露的修改样式的方法
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)]
}
}
新增的部分主要做了这些事情:
- insertCustomElement方法首先检查要插入的自定义元素是否存在,
- 如果存在则用vue的方式通过new方法使用自定义元素,这样使用的好处是:它允许传递不同类型的参数进去,如果使用document.createElement来创建自定义元素,再使用setAttribute设置属性的话,设置的属性仅仅支持string类型,在使用的过程中会增加两步将对象转换为字符串再将字符串转换为对应类型的问题,这是没有必要的
- 接着拿到当前的光标位置,删除选中的所有元素,再根据自定义元素的类型来调用不同的方法处理如何插入的问题
尝试插入CustomLink:
js
import { ref, onMounted, reactive } from 'vue'
import { RichText } from '@/utils/richText/RichText'
let handleClickBtn: (styles: any) => void
let insertLink: (text: string, href: string) => void
const parent = ref()
onMounted(() => {
const richText = new RichText(parent.value,)
// 下面是一些btn实现
handleClickBtn = (styles) => richText.getCustomSelection(styles)
insertLink = (text, href) => {
richText.insertCustomElement('custom-link', { href, text })
}
})
html
<template>
<button @click="handleClickBtn({ color: 'rgb(255, 0, 0)' })">
更改文字为红色
</button>
<button @click="insertLink('baidu', '//www.baidu.com')">插入链接</button>
<div ref="parent" class="parent"></div>
</template>
实现后的效果为:
其中的text内容是可以编辑的,href则只能初始化一次,不可更改(当然修改CustomLink组件也能实现更改)
可能读者会好奇,为什么插入过程那么复杂,为什么不能直接将新建的元素直接插入进去,外面还要包裹一层div并将其contentEditable设置为'false';这就不得不说到webcomponent的特性了:如果我们直接将自定义元素插入到富文本中,则会发生一件事情,那就是当富文本为空的时候插入这个CustomLink后,光标再也无法聚焦到CustomLink外部了,只能编辑CustomLink内部的内容,这就是注释中写的解决shadow dom引发的contenteditable光标丢失的含义。读者可以自行尝试直接插入的结果,将插入行内元素改为这样:
js
// 插入行内自定义元素
private insertInlineCustomElement(el: HTMLElement, range: Range) {
// const container = document.createElement('div')
// container.style.display = 'inline-block'
// container.setAttribute('contenteditable', 'false')
// el.setAttribute('containereditable', 'true')
// container.appendChild(el)
range.insertNode(el)
}
即使按照这个办法解决了光标只能聚焦到自定义元素内部的问题,但是他还是有点小毛病,那就是当光标在自定义元素外部右侧时会消失,不过不影响使用。我去调研了一下知乎的富文本编辑,当他插入一个公式时也会触发这个小问题,他们的解决办法是给元素外部左右都加一个空格,然后让光标聚到空格后面,这样就能展示光标了,大概是这样:
这样也是治标不治本的,当用户删除掉这个空格依然会让光标消失,不过不影响输入就是了。
最后再贴上一个block类型的组件CodeEditor,这是一个简陋的版本,用作示例:
js
<script setup lang="ts">
import { onMounted, ref, onBeforeUnmount } from 'vue'
const props = defineProps({
mode: String,
defaultContent: String
})
const content = ref(props.defaultContent)
const code = ref()
const textarea = ref()
let rowNumber = ref(1)
const setLineHeight = () => {
const activeEl = code.value ?? textarea.value.$el
const height = parseInt(getComputedStyle(activeEl).height)
rowNumber.value = Math.ceil(height / 24)
}
const observer = new ResizeObserver(setLineHeight)
onMounted(() => {
if (code.value) {
observer.observe(code.value, { box: 'border-box' })
}
if (textarea.value) {
observer.observe(textarea.value.$el, { box: 'border-box' })
}
})
onBeforeUnmount(() => {
if (code.value) {
observer.unobserve(code.value)
}
if (textarea.value) {
observer.unobserve(textarea.value.$el)
}
})
defineExpose({
defaultContent: content
})
</script>
<template>
<div class="code-container">
<ul class="list-number">
<li v-for="num in rowNumber" :key="num">{{ num }}</li>
</ul>
<pre class="code" v-if="mode === 'show'"><code><div tabindex="0" ref="code">{{ content }}</div></code></pre>
<a-textarea ref="textarea" v-else v-model:value="content" placeholder="请输入/粘贴代码" auto-size />
</div>
</template>
<script lang="ts">
export default {
name: 'code-editor',
type: 'block'
}
</script>
<style lang="less">
.code-container {
background-color: rgb(31, 31, 31);
color: rgb(204, 204, 204);
font-family: Consolas, 'Courier New', monospace;
font-size: 18px;
position: relative;
display: flex;
padding: 5px 0;
}
.code {
flex: 1;
width: calc(100% - 48px);
line-height: 24px;
margin: 0;
background-color: rgb(31, 31, 31);
padding: 0 8px;
box-sizing: border-box;
div {
overflow-x: auto;
}
}
textarea {
padding: 0 8px;
box-sizing: border-box;
width: 100%;
background-color: rgb(31, 31, 31);
line-height: 24px;
font-size: 18px;
color: rgb(204, 204, 204);
font-family: Consolas, 'Courier New', monospace;
outline: none;
border: none;
}
.list-number {
overflow-x: auto;
list-style: none;
width: 48px;
margin: 0;
padding: 0 8px;
box-sizing: border-box;
text-align: right;
border-right: 1px #ebebeb solid;
background-color: rgb(31, 31, 31);
&>li {
line-height: 24px;
}
}
</style>
这里主要是用到了ResizeObserver来监听textarea高,然后根据高度来计算有多少行了。 他的效果为:
细心的朋友可能会发现,我使用了antdv的组件textarea,但是我仅仅设置了我想要的样式就实现了效果,antdv的默认样式好像丢失了一样,这就是shadow dom样式隔离带来的后果,我们最后会着手解决这个问题。
三、总结
本文主要介绍了在基础的富文本编辑上如何使用vue的defineCustomElement来将一个vue单文件组件打包成一个自定义元素,并如何注册一个自定义元素,以及在插入自定义元素时利用嵌套盒子来避免光标聚焦问题的出现。下一篇文章我将要介绍如何给自定义元素加上一些统一的样式,元素和逻辑