前言:格式刷功能,相信大家都灰常灰常的熟悉,在我们的 element-tiptap 项目里面是没有提供格式刷扩展的,万幸 github 上已经有大神给出了开源的代码 tiptap-extension-format-painter,开源万岁!这篇文章来看一下怎么应用大神给出的源码,为 element-tiptap 项目添加一个格式刷的扩展
开源的库里面有三个文件
其实可以仿照咱们代码里面的其他的定义扩展的代码,把这些代码合并成一个。格式刷的需求比较简单,功能比较单一
- 1、复制开源的代码,创建扩展
- 2、创建一个格式刷的按钮组件
- 3、在菜单中添加格式刷
1、创建扩展
新建文件:src/extensions/format-painter.ts,我把源码中的代码合成了一个文件
import type { JSONContent, ChainedCommands } from '@tiptap/core'
import { Extension } from "@tiptap/core";
const editorClass = 'ProseMirror'
// 检查元素是否包含指定的标签
function eleIncludesTags(ele: HTMLElement, tags: string[]) {
return tags.includes(ele.nodeName)
}
// 检查元素是否包含指定的类
function eleIncludesClasses(ele: HTMLElement, classes: string[]) {
if (!ele || !ele.classList) return false
const classList = ele.classList
return !!classes.find((classname) => classList?.contains(classname))
}
// 获取元素在父元素中的索引
function getIndexInParentElement(ele: HTMLElement) {
return Array.from(ele.parentElement?.childNodes || []).indexOf(ele)
}
type EleDataType = {
id: string | undefined
index: number
isTable: boolean
isLi: boolean
}
export type TStateNotify = (isEnable: boolean) => void
class FormatCopy {
private _isCopying: boolean
get isCopying(): boolean {
return this._isCopying
}
private _cache: JSONContent | undefined
get cacheData(): JSONContent | undefined {
return this._cache
}
private _once: boolean
private _selectionChangeCb
public getChain?: () => ChainedCommands
public stateNotifyList: TStateNotify[] = []
constructor() {
this._isCopying = false
this._once = true
this._cache = undefined
this._selectionChangeCb = this.SelectionChange.bind(this)
}
// 获取当前选中的元素
private _getSelectionEle(): HTMLElement | null {
const selection = window.getSelection()
if (!selection || !selection.anchorNode) return null
const selectEle = selection.anchorNode as HTMLElement
if (
eleIncludesTags(selectEle, ['UL', 'OL', 'TABLE', 'TR', 'CODE', 'BODY']) ||
eleIncludesClasses(selectEle, [editorClass, 'code-block'])
) {
return null
}
return selectEle
}
// 获取选中元素的数据ID
private _getSelectionEleDataId(anchorNode: HTMLElement): EleDataType | null {
let selectEle = anchorNode
let selectEleIndex = getIndexInParentElement(selectEle)
let selectEleDataId = ''
while (
selectEle !== document.body &&
!eleIncludesClasses(selectEle, [editorClass]) &&
!eleIncludesTags(selectEle, ['LI', 'TH', 'TD'])
) {
if (eleIncludesClasses(selectEle, ['code-block'])) return null
if (selectEle.dataset?.id) {
selectEleDataId = selectEle.dataset.id
break
}
selectEleIndex = getIndexInParentElement(selectEle)
selectEle = selectEle.parentElement as HTMLElement
}
if (!selectEleDataId) return null
selectEle = selectEle.parentElement as HTMLElement
const isTable = eleIncludesTags(selectEle, ['TH', 'TD'])
const isLi = eleIncludesTags(selectEle, ['LI'])
return { id: selectEleDataId, index: selectEleIndex, isTable, isLi }
}
// 复制选中元素的格式
Copy(once: boolean, getJSON: () => JSONContent) {
if (this._isCopying) return console.log('copying')
const anchorNode = this._getSelectionEle()
if (!anchorNode) return console.log('no selection element')
const selectEle = this._getSelectionEleDataId(anchorNode)
if (!selectEle) return console.log('not available element', selectEle)
const jsonList = getJSON()?.content || []
let currJson = jsonList.find((item) => item?.attrs?.id === selectEle.id)
// 复制 table 内的样式
if (!currJson && selectEle.isTable)
currJson = this._findMarksInTable(jsonList, selectEle)
// 复制 ul, ol 内的样式
if (!currJson && selectEle.isLi)
currJson = this._findMarksInLi(jsonList, selectEle)
if (!currJson) return console.log('did not find marks', selectEle)
const marks =
currJson.content && currJson.content.length > selectEle.index
? currJson.content[selectEle.index].marks
: currJson.marks
this._cache = { type: currJson.type, attrs: currJson.attrs, marks }
console.log('cache marks:', this._cache)
this._isCopying = true
this._once = once
this._listenSelectionChange()
this._notifyState()
document.body.querySelector(`.${editorClass}`)?.classList.add('formatPainting')
}
// 在表格中查找标记
private _findMarksInTable(jsonList: JSONContent[], selectEle: EleDataType) {
let currJson = undefined
const tables = jsonList.filter((item) => item.type === 'table')
tables.find((item) =>
item.content?.find((tr) =>
tr.content?.find((td) =>
td.content?.find((tdinn) => {
if (tdinn?.attrs?.id === selectEle.id) {
currJson = tdinn
return true
} else {
return false
}
})
)
)
)
return currJson
}
// 在列表中查找标记
private _findMarksInLi(jsonList: JSONContent[], selectEle: EleDataType) {
let currJson = undefined
const uls = jsonList.filter((item) =>
['bulletList', 'orderedList'].includes(item.type || '')
)
uls.find((item) =>
item.content?.find((li) =>
li.content?.find((tdinn) => {
if (tdinn?.attrs?.id === selectEle.id) {
currJson = tdinn
return true
} else {
return false
}
})
)
)
return currJson
}
// 处理选区变化
SelectionChange() {
if (!this.getChain) return this.Stop();
let chain = this.getChain()
chain = chain.focus().unsetAllMarks().clearNodes()
if (this._cache?.marks) {
this._cache?.marks.forEach(({ type, attrs }) => {
console.log('应用样式:', type, { ...attrs })
chain = chain.setMark(type, attrs)
})
}
if (this._cache?.attrs) {
const attrs = this._cache?.attrs
if (attrs.level) {
// @ts-ignore
chain = chain.setHeading({ level: attrs.level })
}
if (attrs.textAlign) {
// @ts-ignore
chain = chain.setTextAlign(attrs.textAlign)
}
chain.run()
}
if (this._once) this.Stop()
}
// 监听选区变化
private _listenSelectionChange() {
const options = this._once ? { once: true } : undefined
document.body.addEventListener('mouseup', this._selectionChangeCb, options)
}
// 停止格式刷
Stop() {
console.log('格式刷 结束')
this._isCopying = false
this._cache = undefined
this._notifyState()
document.body.querySelector(`.${editorClass}`)?.classList.remove('formatPainting')
document.body.removeEventListener('mouseup', this._selectionChangeCb)
}
// 通知状态变化
private _notifyState() {
this.stateNotifyList.forEach(fn => fn(this._isCopying))
}
}
const formatCopy = new FormatCopy()
declare module "@tiptap/core" {
interface Commands<ReturnType> {
formatPainter: {
/** 启用格式刷 */
enableFormatPainter: (options: {
once: boolean;
getChain: () => ChainedCommands;
}) => ReturnType;
};
watchFormatPainterState: (fn: TStateNotify) => void
}
}
export const FormatPainter = Extension.create<void>({
name: "formatPainter",
addCommands() {
return {
// 启用格式刷命令
enableFormatPainter: ({ once, getChain }) => (props) => {
if (formatCopy.isCopying) {
formatCopy.Stop()
} else {
formatCopy.getChain = getChain
formatCopy.Copy(once, props.editor.getJSON.bind(props.editor))
}
return true
},
// 监听格式刷状态变化
watchFormatPainterState: (fn: TStateNotify) => () => {
formatCopy.stateNotifyList.push(fn)
}
};
},
});
2、格式刷按钮组件
① 新建图标 src/icons/format-painter.svg
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke-width="1.5"><g id="group-0" stroke="#333333" fill="#333333"><path d="M6.54279 3.96136C6.54279 3.96136 5.5126 5.16355 4.04854 5.8527C3.04263 6.3262 1.86798 6.43773 1.31475 6.47882C1.11435 6.49371 0.970881 6.68807 1.03837 6.87734C1.33074 7.69728 2.15004 9.6783 3.69591 11.2242C5.82205 13.3503 7.35482 14.192 7.83831 14.4263C7.93747 14.4744 8.05256 14.4563 8.13671 14.3852C8.52204 14.0596 9.63626 13.108 10.3473 12.3987C11.2384 11.5098 12.533 9.9516 12.533 9.9516M13.0123 10.0067C12.8952 10.1238 12.7052 10.1238 12.5881 10.0067L6.32481 3.74339C6.20766 3.62623 6.20766 3.43628 6.32481 3.31913L7.00516 2.63878C7.59095 2.05299 8.54069 2.05299 9.12648 2.63878L10.483 3.99527L12.0867 1.97413C12.6918 1.21155 13.8262 1.14644 14.5146 1.8348C15.2064 2.5266 15.1366 3.66785 14.3657 4.27022L12.3404 5.8527L13.6927 7.20499C14.2785 7.79078 14.2785 8.74053 13.6927 9.32631L13.0123 10.0067Z" stroke-linecap="round" fill="none" vector-effect="non-scaling-stroke"></path></g></svg>
② 新建文件:src/components/menu-commands/format-painter.command.button.vue
<template>
<div>
<command-button
:command="toggleFormatPainter"
:enable-tooltip="enableTooltip"
:tooltip="t('editor.extensions.FormatPainter.tooltip')"
:button-icon="buttonIcon"
icon="format-painter"
:is-active="isCopying"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, inject, ref, onMounted } from 'vue';
import CommandButton from './command.button.vue';
import { Editor } from '@tiptap/vue-3';
export default defineComponent({
name: 'FormatPainterCommandButton',
components: {
CommandButton,
},
props: {
editor: {
type: Object as () => Editor,
required: true,
},
buttonIcon: {
default: '',
type: String
}
},
setup(props) {
console.log('FormatPainterButton setup');
const t = inject('t');
const enableTooltip = inject('enableTooltip', true);
const isCopying = ref(false);
const toggleFormatPainter = () => {
props.editor.commands.enableFormatPainter({
once: true,
getChain: () => props.editor.chain(),
});
};
onMounted(() => {
console.log('FormatPainterButton onMounted');
props.editor.commands.watchFormatPainterState((isEnable) => {
isCopying.value = isEnable;
});
});
return {
t,
enableTooltip,
isCopying,
toggleFormatPainter,
};
},
});
</script>
② 修改 src/extensions/format-painter.ts,需要修改以下几处代码
import type { JSONContent, ChainedCommands, Editor } from '@tiptap/core'
import FormatPainterCommandButton from '@/components/menu-commands/format-painter.button.vue';
const FormatPainter = Extension.create({
name: "formatPainter",
addOptions() {
return {
...this.parent?.(),
button({ editor }: { editor: Editor }) {
return {
component: FormatPainterCommandButton,
componentProps: {
editor,
},
};
},
};
},
// ...
});
export default FormatPainter;
3、在菜单中添加格式刷
① src/extensions/index.ts
export { default as FormatPainter } from './format-painter';
② src/i18n/locales/zh/index.ts 和 src/i18n/locales/zh-tw/index.ts
FormatPainter: {
tooltip: '格式刷',
},
③ src/components/editor.vue 扩展列表中增加 FormatPainter
4、格式刷代码修改
我去,好崩溃啊嘤嘤嘤,这开源代码有问题,不好使🤡🤡🤡。关键在于,说起来有点复杂,我的语言表达能力有点欠缺了,应该看点学说话的书了,提升一下自己的语言能力(一串废话)
我们想一下格式刷的流程,就是在选中一段文本之后,会点击格式刷,那么此时就需要获取选中的文本的样式。但是editor上没有根据当前选区获取选区节点的方法,所以只能用 window.getSelection()
,
但是样式信息都在 editor.state.selection
对象上,这个对象是一个层层嵌套的对象,
关键在于找到 editor.state.selection
对象上和window.getSelection().anchorNode
匹配的节点,好吧后面我发现应该是 window.getSelection().focusNode
这个属性!
好吧,绞尽脑汁,改吧改吧,终于差不多能用了
src/extensions/format-painter.ts
import type { JSONContent, ChainedCommands, Editor } from '@tiptap/core'
import { Extension } from "@tiptap/core";
const editorClass = 'ProseMirror'
import FormatPainterCommandButton from '@/components/menu-commands/format-painter.command.button.vue';
export type TStateNotify = (isEnable: boolean) => void
class FormatCopy {
private _isCopying: boolean // 是否正在复制格式
get isCopying(): boolean {
return this._isCopying
}
private _cache: JSONContent | undefined // 缓存的格式数据
get cacheData(): JSONContent | undefined {
return this._cache
}
private _once: boolean // 是否只复制一次
private _selectionChangeCb // 选区变化的回调函数
public getChain?: () => ChainedCommands // 获取链式命令的方法
public stateNotifyList: TStateNotify[] = [] // 状态通知列表
constructor() {
this._isCopying = false
this._once = true
this._cache = undefined
this._selectionChangeCb = this.SelectionChange.bind(this)
}
// 获取当前选中的元素
private _getSelectionEle(editor: Editor): Node | null {
const s = window.getSelection()
if (!s || !s.focusNode) return null
const focusNode = s.focusNode
// 通过递归检查s.anchorNode是否在表格内
const isInTable = (node: Node | null): boolean => {
if (!node) return false;
if (node.nodeName.toLowerCase() === 'table') return true;
return isInTable(node.parentNode);
};
const tableAncestor = isInTable(focusNode);
console.log('是否在表格内:', tableAncestor);
const selection = editor.state.selection;
console.log(selection)
// 在selection.$anchor.doc中找到和s.anchorNode匹配的节点
let matchedNode = null;
const findMatchingNode = (node: Node) => {
if (node.type.name === 'text' && node.text === focusNode.textContent) {
matchedNode = node;
return false; // 停止遍历
}
return true; // 继续遍历
};
selection.$anchor.doc.descendants(findMatchingNode);
if (matchedNode) {
console.log("找到匹配的节点:", matchedNode);
} else {
console.log("未找到匹配的节点");
}
return matchedNode
}
// 复制选中元素的格式
Copy(once: boolean, getJSON: () => JSONContent, editor: Editor) {
if (this._isCopying) return console.log('copying')
// 获取选中的元素
const anchorNode = this._getSelectionEle(editor)
if (!anchorNode) return console.log('no selection element')
// 缓存选中节点的格式信息
this._cache = {
type: anchorNode.type.name,
attrs: anchorNode.attrs,
marks: anchorNode.marks
}
// 设置格式刷状态
this._isCopying = true
this._once = once
this._listenSelectionChange()
this._notifyState()
document.body.querySelector(`.${editorClass}`)?.classList.add('formatPainting')
}
// 处理选区变化
SelectionChange() {
if (!this.getChain) return this.Stop();
let chain = this.getChain()
// 清除所有标记和节点
// chain = chain.focus().unsetAllMarks().clearNodes()
chain = chain.focus().unsetAllMarks()
// 应用缓存的标记
if (this._cache?.marks) {
this._cache?.marks.forEach(({ type, attrs }) => {
console.log('应用样式:', type, { ...attrs })
chain = chain.setMark(type, attrs)
})
}
// 应用缓存的属性
if (this._cache?.attrs) {
const attrs = this._cache?.attrs
if (attrs.level) {
// @ts-ignore
chain = chain.setHeading({ level: attrs.level })
}
if (attrs.textAlign) {
// @ts-ignore
chain = chain.setTextAlign(attrs.textAlign)
}
chain.run()
}
// 如果只复制一次,则停止
if (this._once) this.Stop()
}
// 监听选区变化
private _listenSelectionChange() {
const options = this._once ? { once: true } : undefined
document.body.addEventListener('mouseup', this._selectionChangeCb, options)
}
// 停止格式刷
Stop() {
console.log('格式刷 结束')
this._isCopying = false
this._cache = undefined
this._notifyState()
document.body.querySelector(`.${editorClass}`)?.classList.remove('formatPainting')
document.body.removeEventListener('mouseup', this._selectionChangeCb)
}
// 通知状态变化
private _notifyState() {
this.stateNotifyList.forEach(fn => fn(this._isCopying))
}
}
const formatCopy = new FormatCopy()
declare module "@tiptap/core" {
interface Commands<ReturnType> {
formatPainter: {
/** 启用格式刷 */
enableFormatPainter: (options: {
once: boolean;
getChain: () => ChainedCommands;
}) => ReturnType;
};
watchFormatPainterState: (fn: TStateNotify) => void
}
}
const FormatPainter = Extension.create({
name: "formatPainter",
addOptions() {
return {
button({ editor, t }: { editor: Editor; t: (...args: any[]) => string }) {
return {
component: FormatPainterCommandButton, // 格式刷命令按钮组件
componentProps: {
editor,
},
};
},
};
},
addCommands() {
return {
// 启用格式刷命令
enableFormatPainter: ({ once, getChain }) => (props) => {
if (formatCopy.isCopying) {
formatCopy.Stop()
} else {
formatCopy.getChain = getChain
formatCopy.Copy(once, props.editor.getJSON.bind(props.editor), props.editor)
}
return true
},
// 监听格式刷状态变化
watchFormatPainterState: (fn: TStateNotify) => () => {
formatCopy.stateNotifyList.push(fn)
}
};
},
});
export default FormatPainter;
还有一个小点,就是小图标的问题,当格式刷的状态的时候,会给编辑器增加一个类名
下载一个小图标的图片:
放在 icons 文件夹中,并且给这个类名增加样式
.formatPainting {
cursor: url('../icons/format-painter-hand.png') 16 16, pointer;
}
5、bug修复
感谢大家的收藏!
后来的后来,我发现了bug,就是如果鼠标是从右往左选中文字,然后点击某个样式,然后再点击格式刷,样式是复制不上的,是因为此时的 focusNode 是我们的目标节点的前一个节点。对于这个问题,我找个很多种解决方案,苦思冥想了一天半,终于找出一个比较流畅的解决方案。
其实问题的关键,在于区分鼠标选择的方向是从左往右还是从右往左。
解决过程非常的曲折,也很值得和大家分享一下。
- 第一种方法:从
selection
对象入手。selecton
对象上有两个属性,一个是focusNode
,一个是anchorNode
,
有了起始位置所属节点,和结束为止所属节点,通过原生DOM的compareDocumentPosition
方法对比这两个节点谁先谁后,就可以知道鼠标选择的方向。但是有时候它们两个指向的是同一个节点,有时候anchorNode
是包含focusNode
的,所以这条线 out! - 第二种方法:重新设置 selection,先获取range,重新设置一下,没用,设置的还和之前一样一样的,选择区域不会统一变成从左向右
- 第三种思路:通过
range
对比位置,这就更难对比了,startContainer
和endContainer
之间的关系有太多种可能…out!
- 第四种思路:将
selection
收起来,使用collapse()
方法,收成一个焦点,这样focusNode
确实是目标节点,但是样式就不是我们想要的了,因为格式刷选中的区域应该一直是选中的状态,out! - 第五种思路:在鼠标落在和鼠标抬起的时候记录两个位置,对比鼠标落下和抬起的x轴。这种办法,在跨行选择的时候,可能会判断错误,out!
最终版本
偶然间,我发现 range
中的 endContainer
是不受方向影响的,也就是说,目标的节点就是 range.endContainer
,这个是可以确定的,暂定它叫做 selectedNode
。另外关于判断鼠标选择方向这个,后面我发现不需要了,但是还会专门写一篇文章讲一下,好不容易找到了完美的方法来着🥴🥴🥴🥴。
selectedNode
是原生的 DOM 元素,而样式信息都存在 tiptap 自建的 Node 中,我们需要找到和 selectedNode
对应起来的 Node
。editor.state.selection
是tiptap中关于选择区域的信息,但是的内容是一层一层的全部都存储起来,其中有一个 $to
,和 $from
属性,我的理解是类似于 focusNode
和 anchorNode
,一个表示起始的位置,一个表示结束的位置。但是并没有找出鼠标选择方向和这两个属性之间有强相关的关系,但是也有一定的规律。我们看一下这两个属性:
其中有一个 path
,就可以根据 path
找到我们目标元素。最后一个 Node
其实就是selectedNode
的父元素,但是我们可以看到,后面的表示索引的数字,一个是 1,一个是 2,经过我的测试,取那个更小的就对了。关键就是这个 1 和 2,我最开始以为这两个索引值跟鼠标移动方向是强相关的,但后面经过测试并不是百分之百的准确,所以只能对比这两个哪个小,来决定使用 $from
还是 $to
。然后还要通过对 path
进行遍历,找出和 selectedNode
对应的 Node
,具体过程放在代码中
src/extensions/format-painter.ts
import type { JSONContent, ChainedCommands, Editor } from '@tiptap/core'
import { Extension } from "@tiptap/core";
const editorClass = 'ProseMirror'
import FormatPainterCommandButton from '@/components/menu-commands/format-painter.command.button.vue';
import { de } from 'element-plus/es/locale';
export type TStateNotify = (isEnable: boolean) => void
class FormatCopy {
private _isCopying: boolean // 是否正在复制格式
get isCopying(): boolean {
return this._isCopying
}
private _cache: JSONContent | undefined // 缓存的格式数据
get cacheData(): JSONContent | undefined {
return this._cache
}
private _once: boolean // 是否只复制一次
private _selectionChangeCb // 选区变化的回调函数
public getChain?: () => ChainedCommands // 获取链式命令的方法
public stateNotifyList: TStateNotify[] = [] // 状态通知列表
constructor() {
this._isCopying = false
this._once = true
this._cache = undefined
this._selectionChangeCb = this.SelectionChange.bind(this)
}
// 获取当前选中的元素
private _getSelectionEle(editor: Editor): Node | null {
const s = window.getSelection();
if (!s || !s.focusNode) return null;
const range = s.getRangeAt(0)
const endContainer = range.endContainer
// range和鼠标选择方向无关
const selectedNode = endContainer
const selection = editor.state.selection;
// path只需要从最后一个Node类型的值开始以及后面的值
// 获取最后一个Node类型的值的位置
const getPath = (path: (number | Node)[]) => {
const nodes = path.filter(item => typeof item == 'object')
const lastNode = nodes[nodes.length - 1]
const lastNodeIndex = path.indexOf(lastNode)
return path.slice(lastNodeIndex)
}
const fromPath = getPath(selection.$from.path)
const toPath = getPath(selection.$to.path)
const nodePath = fromPath[1] > toPath[1] ? toPath : fromPath;
// 在selection.$anchor.doc中找到和s.anchorNode匹配的节点
let matchedNode = null;
const findNodeByPath = (doc, path: number[]) => {
let node = doc
for(let i = 0; i < path.length; i++) {
const item = path[i]
if(typeof item == 'number') {
if(node.content.content.length >= item) {
node = node.content.content[item];
}
} else {
node = item
}
if((node.type.name === 'text'
&& node.text == selectedNode.textContent)) {
break;
}
}
return node;
};
matchedNode = findNodeByPath(selection.$anchor.doc, nodePath)
return matchedNode
}
// 复制选中元素的格式
Copy(once: boolean, getJSON: () => JSONContent, editor: Editor) {
if (this._isCopying) return console.log('copying')
// 获取选中的元素
const selectedNode = this._getSelectionEle(editor)
if (!selectedNode) return console.log('no selection element')
// 缓存选中节点的格式信息
this._cache = {
type: selectedNode.type.name,
attrs: selectedNode.attrs,
marks: selectedNode.marks
}
// 设置格式刷状态
this._isCopying = true
this._once = once
this._listenSelectionChange()
this._notifyState()
document.body.querySelector(`.${editorClass}`)?.classList.add('formatPainting')
}
// 处理选区变化
SelectionChange() {
if (!this.getChain) return this.Stop();
let chain = this.getChain()
// 清除所有标记和节点
// chain = chain.focus().unsetAllMarks().clearNodes()
chain = chain.focus().unsetAllMarks()
// 应用缓存的标记
if (this._cache?.marks) {
this._cache?.marks.forEach(({ type, attrs }) => {
chain = chain.setMark(type, attrs)
})
}
// 应用缓存的属性
if (this._cache?.attrs) {
const attrs = this._cache?.attrs
if (attrs.level) {
// @ts-ignore
chain = chain.setHeading({ level: attrs.level })
}
if (attrs.textAlign) {
// @ts-ignore
chain = chain.setTextAlign(attrs.textAlign)
}
chain.run()
}
// 如果只复制一次,则停止
if (this._once) this.Stop()
}
// 监听选区变化
private _listenSelectionChange() {
const options = this._once ? { once: true } : undefined
document.body.addEventListener('mouseup', this._selectionChangeCb, options)
}
// 停止格式刷
Stop() {
this._isCopying = false
this._cache = undefined
this._notifyState()
document.body.querySelector(`.${editorClass}`)?.classList.remove('formatPainting')
document.body.removeEventListener('mouseup', this._selectionChangeCb)
}
// 通知状态变化
private _notifyState() {
this.stateNotifyList.forEach(fn => fn(this._isCopying))
}
}
const formatCopy = new FormatCopy()
declare module "@tiptap/core" {
interface Commands<ReturnType> {
formatPainter: {
/** 启用格式刷 */
enableFormatPainter: (options: {
once: boolean;
getChain: () => ChainedCommands;
}) => ReturnType;
};
watchFormatPainterState: (fn: TStateNotify) => void,
}
}
const FormatPainter = Extension.create({
name: "formatPainter",
addOptions() {
return {
button({ editor, t }: { editor: Editor; t: (...args: any[]) => string }) {
return {
component: FormatPainterCommandButton, // 格式刷命令按钮组件
componentProps: {
editor,
},
};
},
};
},
addCommands() {
return {
// 启用格式刷命令
enableFormatPainter: ({ once, getChain }) => (props) => {
if (formatCopy.isCopying) {
formatCopy.Stop()
} else {
formatCopy.getChain = getChain
formatCopy.Copy(once, props.editor.getJSON.bind(props.editor), props.editor)
}
return true
},
// 监听格式刷状态变化
watchFormatPainterState: (fn: TStateNotify) => () => {
formatCopy.stateNotifyList.push(fn)
}
};
},
});
export default FormatPainter;
6、又发现一个bug…
还是找目标节点的问题,我发现了一个很简单的办法,之前的办法偶尔还是有bug,更正 _getSelectionEle
方法
// 获取当前选中的元素命令
_getSelectionEle: () => ({ editor }) => {
const s = window.getSelection();
if (!s || !s.focusNode) return null;
const range = s.getRangeAt(0)
const endContainer = range.endContainer
const selection = editor.state.selection;
const matchedNode = selection.$to.nodeBefore;
return matchedNode
}
参考文章:
Range对象与Selection对象