【element-tiptap】如何添加格式刷扩展?

前言:格式刷功能,相信大家都灰常灰常的熟悉,在我们的 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.tssrc/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 对比位置,这就更难对比了,startContainerendContainer 之间的关系有太多种可能…out!
    在这里插入图片描述
  • 第四种思路:将 selection 收起来,使用 collapse() 方法,收成一个焦点,这样 focusNode 确实是目标节点,但是样式就不是我们想要的了,因为格式刷选中的区域应该一直是选中的状态,out!
  • 第五种思路:在鼠标落在和鼠标抬起的时候记录两个位置,对比鼠标落下和抬起的x轴。这种办法,在跨行选择的时候,可能会判断错误,out!

最终版本
偶然间,我发现 range 中的 endContainer 是不受方向影响的,也就是说,目标的节点就是 range.endContainer,这个是可以确定的,暂定它叫做 selectedNode。另外关于判断鼠标选择方向这个,后面我发现不需要了,但是还会专门写一篇文章讲一下,好不容易找到了完美的方法来着🥴🥴🥴🥴。
selectedNode 是原生的 DOM 元素,而样式信息都存在 tiptap 自建的 Node 中,我们需要找到和 selectedNode 对应起来的 Nodeeditor.state.selection 是tiptap中关于选择区域的信息,但是的内容是一层一层的全部都存储起来,其中有一个 $to,和 $from 属性,我的理解是类似于 focusNodeanchorNode,一个表示起始的位置,一个表示结束的位置。但是并没有找出鼠标选择方向和这两个属性之间有强相关的关系,但是也有一定的规律。我们看一下这两个属性:
在这里插入图片描述
其中有一个 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对象

### 如何在项目中安装和配置 element-tiptap #### 安装依赖项 为了成功集成 `element-tiptap` 到 Vue 项目中,需先确保环境已经准备好。这涉及到安装一些必要的包,其中包括 `element-plus` 和 `element-tiptap` 自身。 对于基于 Vue3 的项目而言: ```bash npm install element-plus @wangeditor/editor @wangeditor/editor-for-vue ``` 接着,还需特别针对 `element-tiptap` 进行安装操作[^1]: ```bash npm install element-tiptap ``` #### 配置全局样式 为了让 `element-tiptap` 正常工作并显示预期效果,在项目的入口文件(通常是 main.js 或 app.ts 中),引入所需的 CSS 文件也是必不可少的一个环节。 ```javascript import 'element-plus/lib/theme-chalk/index.css'; // 导入 Element Tiptap 样式 import '@wangeditor/editor/dist/css/style.css'; ``` #### 创建富文本编辑器实例 完成上述准备工作之后,则可以在具体页面或组件内初始化该插件了。下面是一个简单的例子来展示怎样快速上手使用这个强大的工具[^2]。 ```vue <template> <div id="app"> <!-- 使用 el-tiptap-editor 组件 --> <el-tiptap-vuetify v-model="content" :extensions="extensions"/> </div> </template> <script> import { doc, text, paragraph } from 'element-tiptap'; export default { data() { return { content: '', // 编辑区初始内容为空字符串 extensions: [ new doc(), new text(), new paragraph() ] }; } }; </script> ``` 通过以上步骤就可以顺利地把 `element-tiptap` 添加到自己的 Vue 应用程序当中去了。值得注意的是,实际开发过程中可能还需要根据官方文档进一步调整设置以满足特定需求[^3]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值