构建强大编辑器:深入剖析 Prosemirror Mark 及选区与光标系统的奥秘

1. Prosemirror 中的点缀 Mark

​ 之前几篇文章中都提到了 Mark 的概念,与 Node 相比,Mark 就简单多了,之前说过,Prosemirror 文档结构是由 Node 构建的,Mark 只是对一些 inline 内容的装饰,如:加粗斜体下划线删除线上标下标字体颜色背景色行内代码对齐方式字体字号链接 等,主流的文档编辑器如语雀、飞书文档、Notion等也就提供前面列出来的那几种了,"等"字甚至都不用加。

​ 知道了它的应用场景,也就大概能明白它与 Node 有什么区别了,之前我们写的 Node 主要是用来构成 Prosemirror 的文档结构的,而 Marks 主要是用来设置文本格式的,因此一段文本同时可能有多个 Mark,但一个 Node 是不可能同时既是标题,又是正文的,虽然这两者可以是父节点子节点的关系。

​ 说到这里,突然就看到了 typora 左上方菜单的划分:也刚好是根据我们所说的 Node(段落) 与 Marks(格式) 做为分类的

​ 因此,我们将 Marks 称之为 Prosemirror 中的点缀,它与 Node 一起构成了 Schema,但从体量上来看,它并没有 Node 那么复杂,当然在 API 设计层面也是如此。对于 Mark 的学习与探索,我们的重点则为如何操作 mark,在文档中如何对文本进行格式设置,这就需要了解到 Prosemirror 中的选区系统,光标系统等概念,才能更好地操作文档,接下来我们就来探索一下 Prosemirror 中的 Selection 与 光标系统以及如何定义与操作 Mark 吧。

2. 揭开 Mark 的神秘面纱

2.1 回顾一下语义化标签

​ 对于上面提到的一些文本格式,在 html 中大多都有一些语义化的的标签,默认就会展示对应的格式,我们需要先了解一下,在实现具体 Mark 的时候,如果能用到这些标签,优先使用语义化标签,其他没有语义化标签的可以使用 span 标签来包裹,css 来设置样式。

html 复制代码
<div>
  <b>加粗(b) bold</b><strong>加粗 strong</strong>
  <i>斜体(i) italic</i><em>强调(斜体)</em>
  <u>下划线 (u) undeline</u>
  <s>删除线 (s) strike</s> <del>delete</del> <strike>strike html4定义,已废弃</strike>
  <sub>下标 (sub) subscript</sub>
  <sup>上标 (sup) superscript</sup>
  <code>行内代码 code</code>
  <font>font标签(已废弃)</font>
</div>

// 常见的 mark 对应的语义化标签
// 加粗 strong
// 斜体 em
// 删除线 s
// 下划线 u
// 上标 sup
// 下标 sub
// 行内代码 code
// 下面这些没有对应的语义化标签可以用 span 标签
// 字体大小
// 字体
// 字体颜色
// 背景颜色
// 对齐方式

2.2 Mark 的定义

​ 在 Prosemirror 中,Mark 的定义最关心的两个属性是 toDOM(类似 Vue React 中定义组件) 与 parseDOM(解析粘贴进来的 html),这两个属性我们已见怪不怪了,它与 Node 中的 toDOM 与 parseDOM 是完全相同的作用,并且如果你需要,也是可以添加 attrs (类似 Vue React 中组件的 Props, 最终会传给 toDOM,如下方链接的定义),在富文本中,要注意,通常设置文本样式并不是通过 css 来设置的,而是需要通过一些标签包裹,这个原因也很简单,你想想 如果 hello world 中的 hello 要求用斜体,world 要求正常,你能直接在整个段落上加 font-style 吗?因此,Mark 的定义,主要也是设置这些包裹的标签,通常都是一些内联元素。

​ 对于 parseDOM,其规则则与 Node 中的规则完全一致,类型都为 ParseRule ,特别注意的是下面使用的 getAttrs,在通过 tag 匹配内容时, getAttrs 参数为 domNode,通过 style 匹配规则时,参数为字符串,字符串是对应 style 的值。getAttrs 返回 false 表示当前规则不匹配,不匹配的则不会被解析为当前的 mark,返回 undefined 或 null 则会为当前 mark 创建一个空的 attrs,如果正常返回内容,返回的内容则为从当前规则中解析出来的 attrs。

ts 复制代码
const schema = new Schema({
  ...
  marks: {
    // 常见的 mark
    // 加粗 b, strong(语义化)
    bold: {
      toDOM: () => {
        return ['strong', 0]
      },
      parseDOM: [
        { tag: 'strong' },
        { tag: 'b', getAttrs: (domNode) => (domNode as HTMLElement).style.fontWeight !== 'normal' && null },
        { style: 'font-weight', getAttrs: (value) => /^(bold(er)?|[5-9]\d{2})$/.test(value as string) && null }
      ]
    },
    // 斜体 em
    italic: {
      group: 'heading',
      toDOM: () => {
        return ['em', 0]
      },
      parseDOM: [
        { tag: 'em' },
        { tag: 'i', getAttrs: (domNode) => (domNode as HTMLElement).style.fontStyle !== 'normal' && null},
        { style: 'font-style=italic' },
      ]
    },
  },
  // 链接
  link: {
      group: 'heading',
      attrs: {
        href: {
          default: null
        },
        ref: {
          default: 'noopener noreferrer nofollow'
        },
        target: {
          default: '_blank'
        },
      },
      toDOM: (mark) => {
        const { href, ref, target } = mark.attrs;
        return ['a', { href, ref, target  }, 0]
      },
      parseDOM: [
        {
          tag: 'a[href]:not([href *= "javascript:" i])'
        }
      ]
    },
})	

2.3 NodeSpec 中的 marks 字段

​ 在上篇文章中介绍 Node 时提到了 marks 字段,但没说明他的作用,它其实就对应到这里的 Mark,marks 字段为字符串,如果为空字符串,则代表当前节点下的文本不接受任何 marks 设置,如果为 _ 则代表允许设置任何 marks,如果不手动指定,默认也是接受全部。除此之外,也可以自行指定,如 "bold italic" 为允许设置 bold 与 italic,当然 Mark 定义时,可以为 Mark 指定分组,如上 italic 中设置了 group 为 heading,这是我们自定义的分组,因为在 标题中,一般我们是不允许 Bold 加粗的,但如果一个一个为 heading 指定 marks,则太费劲了,我们可以指定为 maks: "heading",后续允许在 heading 上设置的 marks 可以都加到 heading 分组中。

3. Mark 的实际操作与特殊属性

​ 其实在完全搞明白 Node 的定义之后,再看 mark,可能已经没太多能讲的了,它的基本属性在 Node 中都有完全相同的,但 Marks 的另一个重点,是对 Mark 进行操作。

3.1 已有代码的重构,支持快速添加 toolbar 按钮

​ 在开始之前,先切回我们的代码,之前通过 dom API 创建 dom 节点,属实太啰嗦,且影响阅读。我们想个办法将其优化一下,首先,创建按钮与绑定点击事件我们可以封装起来,其次,在编辑器文档更新的时候,我们可能会需要动态更新 dom,例如光标移动到一处被加粗的文本上,对应的设置加粗的按钮应该高亮。

ts 复制代码
// crel 就是个 createElement 的缩写,用来创建 dom 元素的,感兴趣的可以看看源码就几十行
import crel from 'crelt'
import { EditorState, Transaction } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
// 抽象 menu 的定义,不要每次都定义很多 html
/**
 * const btn = document.createElement('button')
 * btn.classList.add('is-active') // 当前 btn 激活
 * btn.classList.add('is-disabled') // 当前 btn 禁用
 * btn.onClick = fn // 点击 btn 后的效果
 * 
 * update btn style
 */

export interface MenuItemSpec {
  class?: string;
  label: string;
  handler: (
    props: {
      view: EditorView;
      state: EditorState;
      tr: Transaction;
      dispatch: EditorView['dispatch'];
    }, 
    event: MouseEvent
  ) => void;
  update?: (view: EditorView, state: EditorState, menu: HTMLElement) => void;
}

export class MenuItem {
  constructor(private view: EditorView, private spec: MenuItemSpec) {
    const _this = this;
    // 创建 button
    const btn = crel('button', { 
      class: spec.class, 
      // 绑定点击事件,点击按钮时要执行的函数
      onclick(this, event: MouseEvent) {
        // 把 view state 等内容传过去,因为点击按钮的时候不是增加一个node,就是要设置 mark
        spec.handler({
          view: _this.view,
          state: _this.view.state,
          dispatch: _this.view.dispatch,
          tr: _this.view.state.tr
        }, event)
      }
    })

    btn.classList.add('menu-item')

    btn.innerText = spec.label;

    // 将 btn 绑定在当前组件上
    this.dom = btn;
  }

  dom: HTMLElement;
  
  // 定义一个 update 更新方法,在编辑器有更新的时候就调用
  update(view: EditorView, state: EditorState) {
    this.view = view;
    this.spec.update?.(view, state, this.dom)
  }
}

​ 我们希望一个按钮组为一个 div,里面可以放很多按钮,不需要每次增加一个按钮我们就得自己 new 一次 MenuItem,只需要 new 一个 MenuGroup,里面填写上 items 配置项,将 MenuItem 的创建交给 MenuGroup:

ts 复制代码
import crel from 'crelt';
import { EditorView } from "prosemirror-view";
import { MenuItem, MenuItemSpec } from "./menu-item";
import { EditorState } from 'prosemirror-state';

export interface MenuGroupSpec {
  name?: string;
  class?: string;
  menus: MenuItemSpec[];
}

export class MenuGroup {
  constructor(private view: EditorView, private spec: MenuGroupSpec) {
    // 创建一个 div
    const dom = crel('div', { class: this.spec.class })
    dom.classList.add('menu-group')

    // 将 dom 保存在 MenuGroup 实例属性上
    this.dom = dom;
    // 通过传递的 menus 配置项,批量创建 menu
    this.menus = spec.menus.map((menuSpec) => new MenuItem(this.view, menuSpec))

    // 最后将 menu 对应的 dom 添加到 menuGroup 的 dom 中
    this.menus.forEach(menu => {
      dom.appendChild(menu.dom)
    })
  }

  private menus: MenuItem[]
  
  dom: HTMLElement;

  // 定义一个 update, 主要用来批量更新 menu 的 update
  update(view: EditorView, state: EditorState) {
    this.view = view;
    this.menus.forEach(menu => {
      menu.update(view, state)
    })
  }
}

抽象 toolbar 的定义

​ 其实还不够,我希望添加 Node 节点的按钮与设置 Mark 的按钮可以分两行,或者更多,可以为编辑器增加一个 toolbar,把这些按钮都塞进去,它与 MenuGroup 的封装逻辑一致。

ts 复制代码
import crel from 'crelt';
import { EditorView } from "prosemirror-view";
import { MenuGroup, MenuGroupSpec } from "./menu";
import { EditorState } from 'prosemirror-state';

export interface ToolbarSpec {
  groups: MenuGroupSpec[]
  class?: string
}

export class Toolbar {
  constructor(private view: EditorView, private spec: ToolbarSpec) {
    // 定义一个 toolbar dom
    const toolbarDom = crel('div', { spec: this.spec.class })
    toolbarDom.classList.add('toolbar');

    // 将 dom 保存在 Toolbar 实例属性中
    this.dom = toolbarDom;

    // 批量创建 menuGroup
    this.groups = this.spec.groups.map(groupSpec => new MenuGroup(this.view, groupSpec))
    
    // 把 menuGroup 分别加入到 toolbar 中
    this.groups.forEach(group => {
      this.dom.appendChild(group.dom)
    })

    this.render();
  }

  // 这个 render 比较特殊,我们可以通过 view.dom 获取到 Prosemirror 编辑器挂载的 dom
  // 之后获取到它的父节点,将 toolbar 塞到 编辑器节点的前面去:这里先将 view.dom 替换成 toolbar 再把 view.dom append 上去
  // 你也可以直接用 insertBefore 之类的 api 
  render() {
    if (this.view.dom.parentNode) {
      const parentNode = this.view.dom.parentNode;
      const editorViewDom = parentNode.replaceChild(this.dom, this.view.dom);
      parentNode.appendChild(editorViewDom)
    }
  }

  groups: MenuGroup[]

  dom: HTMLElement;
  // 定义 update,主要用来批量更新 MenuGroup 中的 update
  update(view: EditorView, state: EditorState) {
    this.view = view;
    this.groups.forEach(group => {
      group.update(this.view, state);
    })
  }
}

替换之前手动写的 DOM API

替换完成后,我们的代码就变成了类似配置式的,看起来更简洁舒适了,也更容易阅读

ts 复制代码
const toolbar = new Toolbar(editorView, {
    groups: [
      {
        name: '段落',
        menus: [
          {
            label: '添加段落',
            handler: (props) => {
              const { view } = props;
              insertParagraph(view, '新段落')
            },
          },
          {
            label: '添加一级标题',
            handler: (props) => {
              insertHeading(props.view, '新一级标题')
            },
          },
          {
            label: '添加 blockquote',
            handler: (props) => {
              insertBlockquote(props.view)
            },
          },
          {
            label: '添加 datetime',
            handler: (props) => {
              insertDatetime(props.view, Date.now())
            },
          }
        ]
      }
    ]
  })

在编辑器更新时候调用 update

​ 上面写完后,还差一步,我们之前在 Toolbar、GroupMenu 以及 MenuItem 中定义了 update 方法,需要在编辑器每次内容更新后调用一遍,我们可以在实例化 EditorView 时,传入 dispatchTransaction,如果还记得之前讲过的 tr,类似于 MVC 中的 控制器角色,那么这里它又来了,tr 是 transaction 的简写,Prosemirror 中对编辑器的所有修改,都只能通过 transaction 进行,每次触发的 tr 都是一个新的 Transaction 实例,我们在上篇文章中也用到过 view.dispatch(tr),通过 dispatch 方法会手动提交一个事务(transaction),除此之外,编辑器内容的修改,甚至是选区变化(包含光标移动),都会自动触发 tr。这里 dispatchTransaction 中可以拦截到这些 tr 的提交。

​ 如果这个函数中什么都不写,代表我们接收到提交的 tr 后什么都不做,就不会更新编辑器状态,所以页面上表现就是什么都输不进去,任何按钮操作都没用。在 MVC 模式下,控制器最终还是要修改并提交数据后,内容才能在视图中更新,所以我们拦截到改动提交时,要手动更新数据,通过 editorView.state.apply(tr) 可以根据 tr(事务,其中包含对数据的修改),创建一个新的编辑器数据 state,再通过 editorView.updateState(newState) 可以将新的数据状态应用到视图中,就可以手动完成编辑器内容更新的最后一步了,编辑器内容更新后,调用 toolbar.update 即可批量调用我们在 MenuGroup -> MenuItem 上的的 update。

ts 复制代码
// 创建编辑器视图实例,并挂在到 el 上
  const editorView = new EditorView(editorRoot, {
    state: editorState,
    //
    dispatchTransaction(tr) {
      let newState = editorView.state.apply(tr);
      editorView.updateState(newState);
      toolbar.update(editorView, editorView.state)
    }
  })

3.2 Prosemirror 的选区系统基础

​ 为什么要先讲选区?你可以想象在使用飞书文档、语雀、Notion 的时候,大家通常如何设置文本?是不是先选择自己要设置的那一段文本,再点击加粗、颜色之类的按钮进行设置。所以在编辑器中选区系统是个非常重要的一个环节,它是编辑器进行操作时的基础。

​ Prosemirror 中的选区底层是通过浏览器自身的选区实现的,这也是目前大家讨论的所谓 L2 编辑器,即依赖浏览器的 contenteditable 属性实现编辑器,但数据与逻辑是由编辑器框架控制的,光标与选区基本也是依赖浏览器本身的光标与选区。但其实光标与选区也是可以定制的,例如 WPS 智能文档中光标系统是自行实现的虚拟光标系统,选区也是通过 SVG 进行绘制的,其实实现了这两部分,它就已经不算是 L2 了, 至少是 L2.5,因为还是依赖浏览器的排版,例如 Google Docs 这种 L3 的排版也是基于 Canvas 的。所以还是差一点点,但个人认为如果是做在线文档的话 L2 在 80% 的场景下已经足够了。例如飞书文档、Notion、语雀等,实际体验还是很不错的。

Prosemirror 中的光标系统

​ 要讲选区,必须先认识到光标位置,我们这里讲的光标系统实际上是光标位置计算系统。在上篇文章中使用 Node 的时候,也用到了 selection.anchor 之类的 API 获取位置,那这个位置到底是什么,它是怎么计算的?

​ 还记得之前设计的文档结构吗?再来回顾一下

​ 然后就是这个结构,我增加一些文本

​ 你知道光标在每个文字后面的时候,它的位置该怎么算吗?例如光标在上面的地方,是 2 吗?

​ 很遗憾,通过 editorView.state.selection 查看选区相关信息,可以看到光标应该是在 4 的位置,如果你不看文档,或者看了文档,却没有完全理解光标系统,那可能对于 4 的计算会很难理解,或者在实际开发中,经常因为 pos 的计算错误导致 bug,并且这种 bug 也不是很好排查。笔者在实际过程中也遇到过很多次 pos 相关的问题,所以这一部分是重中之重,异常重要。

​ 如果你只是看官方文档,那里给出的图可能会对你产生误解,这里我以 xml 的形式,以我们定义的 Node 为标签,来将我们的文档展示出来。doc 是我们文档根节点,block_tile 是我们定义的类似与 layout 一样作用的节点,heading 代表标题,paragraph 为段落......,好家伙,之前不是说文档是由 Node 构成的嘛?并且以 Vue React 组件类比了 Node 的定义,在这里,又是完美的契合。

​ Prosemirror 中的光标系统是基于 Node 算的,之所以有时候会误解,很大一部分原因是因为官方文档中的实例画的是 html 标签。光标位置系统开始位置是 0,每个标签大小都算 1,这里说的标签是前面那半个,不包含后面的闭合标签,依次往后,后面的闭合标签大小也是 1,根据上面的图,就不难看出之前光标位置为什么是 4 了,最后一排我没画出来,小伙伴可以自己数一下,如果光标在 这是引用 的前面,那应该是多少?如果没数错的话,那就是 19。

​ 再讲 nodeSize,知道了光标系统,有个之前用到的内容 nodeSize 就有了合理的计算依据,第一个 block_title 大小是 8,整个 docnodeSize(8 * 2) + 10 + 2 = 28 , 8 * 2 是上面标题和段落各自大小,10 是下面这是引用的大小,还有 +2 是 <doc></doc>; 然后打印一下我们的 doc(editorView.state.doc),查看我们说的对不对

​ 还记得上篇文章讲到的 atom 特殊属性吗?当时说设置了 atom 之后,这个 node 就会被认为是一个原子节点,nodeSize 为1,里面不能再输入富文本了,是不是在定义 toDOM 的时候,返回的内容也没有添加 0(洞)或者 contentDOM,是没有留下类似 slot 一样的插槽的。

​ 加入一个 datetime 节点之后,doc.nodeSize 从 28 变为 29。

Prosemirror 中的 Selection 是什么样的

​ 当鼠标从左开始向右选择到第二行时,鼠标开始点击的地方叫做 anchor 锚点(下锚定住的基本点),鼠标选择结束后停止的地方叫做 head 头部,当我们说 anchor 锚点的时候,就是带方向的。除此之外,还有一套 fromto,是不分方向的,from 始终是小的那一边,to 始终是大的那一边。

​ 如果还是上面的选区,但是选择方向是反的,这几个值分别是什么?大家可以自己算一下,下面我也给出图:

​ 除此之外,当我们的鼠标没有选中任何内容,仅仅是光标呢?

​ 当仅仅是光标时,这几个值都是相同的,并且 emptytrue

​ 同时,我们还发现一套带 $ 符号,命名相同的变量,其实带 $ 的那几个值,跟上面讲的一样,但他们是通过 editorView.state.doc.resolve(pos) 计算出来当前这个 pos 位置的更丰富的信息:例如当前位置的 depth,pos,父元素 parent,后面一个节点 nodeAfter, 前面一个节点 nodeBefore,以及 path 当前节点的路径等。我们通常需要根据一个位置快速获取到当前位置的节点是什么,这个 api 计算的结果就很有用。当然上面还有个 $cursor,这个属性与 empty 是相同作用,在是光标状态的时候 $cursor 才有值,否则为 null.

depth 是怎么计算的?

​ 上面提到的 resolve 解析完一个位置后,会获取到这个位置的 depth ,那 depth 是怎么算的呢?我们可以这么来看,如果当前位置位于 doc 中,那它的 depth 就是 0,位于 block_tile 中就是 1,位于heading 下就是 2, 即当前节点的深度。

Range 与 Selection 的区别

​ 在 Prosemirror 中 SelectionRange 只有两个属性,即 $from$to, 其实熟悉浏览器原生 Selection 与 Range API 的话,就很好理解这里的 Range 了,这里的 Range 应该是作者对标浏览器标准来的,但与原生有点不一样,原生 Range 非常强大,但 Prosemirror 中的 Range 其实就代表了我们具体的选区范围。

​ 在原生 API 中实际上我们在真实浏览器中选择的一块一块的范围,在 Firefox 中按住 ctrl 是可以选择多段的,每一段才是真的 Range 的概念,整个选区其实是个逻辑上的概念,并不对应到真实页面上的哪一个部分。在 Prosemirror 中 弱化了 Range 的概念,但 Range 也与原生的概念差不多,都是选区中一块一块被选中的范围,不过在 Prosemirror 中,虽然 Selection.ranges 是个数组,但实际上一般里面只有一个 Range。

​ 如果想要了解浏览器原生 Selection 与 Range,可以查看之前的文章 点亮富文本编辑器的魔力:Selection与Range解密

3.3 探索为内容设置格式

增加 Bold 设置入口

增加 Bold 设置按钮入口,点击加粗,对所选内容加粗,点击取消加粗,则执行对应行为。

ts 复制代码
new Toolbar(editorView, {
    groups: [
      // ...
      {
        name: '格式',
        menus: [
          {
            label: '加粗',
            handler(props) {
             setBold(props.view)
            }
          },
          {
            label: '取消加粗',
            handler(props) {
             unsetBold(props.view)
            }
          }
        ]
      }
    ]
  })

function setBold(view: EditorView) {}
function unsetBold(view: EditorView) {}

通过 tr 对内容设置 mark

创建一个 mark:

​ 首先要了解如何创建一个 mark, 与 Node 类似,创建 mark 可以通过 schema 也可以通过 MarkType 实例,MarkType 实例实际就是我们创建 Schema 时候,传进去的 marks(传的时候他们叫 MarkSpect),由他们实例化出来的 MarkType 对象,通过 schema.marks 可以进行访问。

ts 复制代码
// 通过 schema  创建 mark
schema.mark(markType,attrs)
// 通过 markType 创建 mark,例如 bold,这里的 bold 是我们定义 schema 时候传进去的 key 值,可以通过这个 key 获取到实例化后的 MarkType
const boldType = schema.marks.bold;
const boldMark = boldType.create(attrs)

为内容添加 mark:

​ 我们之前说过,mark 是给行内内容增加样式或附属信息的。所以我们 mark 的设置一般都是在普通文本上。通过 tr.addMark(from, to, mark) 可以给 from -> to 的文本内容设置样式。

ts 复制代码
/**
 * 设置 mark
 * 
 * @param view 
 * @param markType 
 * @param attrs 
 */
function setMark(view: EditorView, markType: MarkType | string, attrs: Attrs | null = null) {
  const { schema, selection, tr } = view.state;
  const { $from, $to } = selection;

  // 根据 schema.mark 创建 mark,因为 markType 可以传入字符串,如果通过 MarkType,我们还需要先根据 shema.marks[markType] 先获取真正的 markType
  const mark = schema.mark(markType, attrs)

  // 为选区中 from -> to 的内容增加 mark
  tr.addMark($from.pos, $to.pos, mark)
  
  // 派发 tr 触发更新
  view.dispatch(tr);
  
  return true
}

​ 这里我们先不管边界情况,就直接调用 api 增加 mark 就完了,当然上面只是一层核心的封装,我们还需要对具体场景进行再次封装,这里主要以 Bold 加粗为例:

ts 复制代码
/**
 * 设置加粗
 * 
 * @param view 
 * @returns 
 */
export function setBold(view: EditorView) {
  const boldMarkType = view.state.schema.marks.bold;

  return setMark(view, boldMarkType);
}

​ 在 Toolbar 中增加配置,然后我们的加粗功能就实现了

ts 复制代码
new Toolbar(editorView, {
  groups: [
    //...
    {
      name: '格式',
      {
        label: '加粗',
        handler(props) {
    			// handler 中调用设置加粗
          setBold(props.view);
  				// 加粗后再将编辑器聚焦
          props.view.focus();
        }
      }
    }
  ]
})

​ 我们可以看到,增加的加粗,就是我们在定义 Bold 的时候,toDOM 中定义的内容,通过 ["strong",0] 用 strong 包裹内部的内容(「0」洞)

通过 tr 取消 mark 的设置

​ 我们增加了 mark 后,如何取消 mark 呢?通过 tr.removeMark(from, to, markType) 可以删除 from 到 to 的 mark

ts 复制代码
/**
 * 取消 mark
 * 
 * @param view 
 * @param markType 
 */
function unsetMark(view: EditorView, markType: MarkType | string) {
  const { schema, selection, tr } = view.state;
  const { $from, $to } = selection;
  
  const type = typeof markType === 'string' ? schema.marks[markType] : markType;

  tr.removeMark($from.pos, $to.pos, type);
  
  view.dispatch(tr)

  return true;
}

/**
 * 取消加粗
 * 
 * @param view 
 * @returns 
 */
export function unsetBold(view: EditorView) {
  const boldMarkType = view.state.schema.marks.bold;

  return unsetMark(view, boldMarkType);
}

// toolbar 中增加按钮与调用
{
  //...
  {
      label: '取消加粗',
      handler(props) {
        unsetBold(props.view);
        props.view.focus();
      }
    }
  //...
}

判断当前是否处于加粗状态,动态更新加粗按钮样式

​ 当前有了加粗与取消加粗,我们需要优化一下用户体验,只提供一个按钮 B,如果选区当前已经加粗了,他就高亮,点击后取消加粗,否则,就是普通按钮状态,点击可以高亮。toggle 倒是好实现了,但这里的问题就在于当前如何判断选区内容已经增加了加粗效果呢?别忘了我们之前重构 Toolbar 时候做了什么事,在浏览器状态更新的时候,都会执行我们 menu 的 update 事件,并且把 view 、state 以及对应的按钮 dom 元素都传了过来,现在只需要想办法知道选区是否高亮就好了

ts 复制代码
// 将获取 markType 的功能封装为函数方便调用
function getMarkType(markType: MarkType | string, schema: Schema) {
  return typeof markType === 'string' ? schema.marks[markType] : markType;
}
// 判断当前 selection 是否是 文本选区,prosemirror 中除了文本选区,还有 Node 选区 NodeSelection,即当前选中的是某个 Node 节点而不是文本
function isTextSelection(selection: unknown): selection is TextSelection {
  return selection instanceof TextSelection;
}
/**
 * 选区内所有的内容都被设置了 mark,那就是 active
 * 
 * @param view 
 * @param markType 
 */
function isMarkActive(view: EditorView, markType: MarkType | string) {
  const { schema, selection, tr } = view.state;

  // 暂时规定:如果不是文本选区,就不能设置 mark
  if (!isTextSelection(selection)) {
    return false;
  }

  const { $from, $to } = selection;
  
  const realMarkType = getMarkType(markType, schema);

  let isActive = true;
  // doc.nodesBetween(from, to, (node, pos) => void) 可以遍历从 from 到 to 的所有节点,如果返回 false, 就不会再继续深入遍历了。这个方法大家可以自己在浏览器中尝试用一下,探索一下他的功能
  tr.doc.nodesBetween($from.pos, $to.pos, (node) => {
    if (!isActive) return false;
    // 这里之所以是 node.isInline,是因为我们之前讨论过,mark 都是设置在行内内容上的
    if (node.isInline) {
      // markType.isInset(marks[]) 可以判断当前 marks 中是否包含当前 markType 类型的 mark
      const mark = realMarkType.isInSet(node.marks)
      if (!mark) {
        // 如果 有任意一个 不包含,则设置 active 为 false,即当前可以设置 mark
        isActive = false;
      }
    }
  })

  return isActive;
}

/**
 * toggle mark
 * 
 * @param view 
 * @param markType 
 * @returns 
 */
function toggleMark(view: EditorView, markType: MarkType | string) {
  if (isMarkActive(view, markType)) {
    return unsetMark(view, markType)
  } else {
    return setMark(view, markType)
  }
}

// 其实也没必要封装了,后续可以直接使用 isMarkActive() 传入字符串
export function isBold(view: EditorView) {
  const boldMarkType = view.state.schema.marks.bold;
  
  return isMarkActive(view, boldMarkType)
}

// toggleBold
export function toggleBold(view: EditorView) {
  return toggleMark(view, 'bold');
}

// toolbar 配置
{
  label: 'B',
  handler(props) {
    toggleBold(props.view);
    props.view.focus();
  },
  update(view, state, menuDom) {
    // 编辑器更新时,判断是当前选区内容是否已经设置为 bold, 根据条件,为 menu 增加 is-active 类
    const isActive = isBold(view)
    if (isActive && !menuDom.classList.contains('is-active')) {
      menuDom.classList.add('is-active')
    }

    if (!isActive && menuDom.classList.contains('is-active')) {
      menuDom.classList.remove('is-active')
    }
  }
}

​ 看起来功能可以用了,但是有个问题,当选区处于光标情况下的时候,我们的判断是不对的,所以在输入内容的过程中发现 bold 也是高亮的。我们确实是忽略了仅仅有光标,而不是选区的情况!

编辑器只有光标而不是选区的边界情况处理

​ 先分系一下,当我们处于光标情况下的时候,如何判断当前是否应该高亮按钮?也就是下一步输入是否应该是加粗的状态?

​ 在 prosemirror 的 tr 中,有个 storedMarks 规定了下一次输入的时候是否应该有某些 mark,我们可以先尝试一下,光标情况下,给 tr 的 storedMarks 加上加粗的 mark 下一次输入能不能正常输入 mark,改造一下 setMark,增加一个光标状态下的处理逻辑。当然同时这个逻辑也得给 isMarkActive 加。

ts 复制代码
/**
 * 设置 mark
 * 
 * @param view 
 * @param markType 
 * @param attrs 
 */
function setMark(view: EditorView, markType: MarkType | string, attrs: Attrs | null = null) {
  const { schema, selection, tr } = view.state;
  const { $from, $to, empty } = selection;

  const realMarkType = getMarkType(markType, schema);
  const mark = realMarkType.create(attrs);

  // 光标状态,如果 storedMarks 里没有 当前 mark,就把当前 mark 加进去
  if (empty) {
    if (!realMarkType.isInSet(tr.storedMarks || [])) {
      tr.addStoredMark(mark)
    }
  } else {
    // 否则再执行之前的逻辑
    tr.addMark($from.pos, $to.pos, mark);
  }

  view.dispatch(tr);

  return true;
}

/**
 * 选区内所有的内容都被设置了 mark,那就是 active
 * 
 * @param view 
 * @param markType 
 */
function isMarkActive(view: EditorView, markType: MarkType | string) {
  const { schema, selection, tr } = view.state;

  // 暂时规定:如果不是文本选区,就不能设置 mark
  if (!isTextSelection(selection)) {
    return false;
  }

  const { $from, $to, empty } = selection;
  
  const realMarkType = getMarkType(markType, schema);

  let isActive = true;

  // 增加 光标情况下,判断当前是否处于 markType 下
  if (empty) {
    if (!realMarkType.isInSet(tr.storedMarks || [])) {
      isActive = false;
    }
  } else {
    tr.doc.nodesBetween($from.pos, $to.pos, (node) => {
      if (!isActive) return false;
      if (node.isInline) {
        const mark = realMarkType.isInSet(node.marks)
        if (!mark) {
          isActive = false;
        }
      }
    })
  }
  

  return isActive;
}

​ 但目前有个新问题,就是 tr 中设置了 storedMarks 之后,只要我有输入,下一次 tr 中 storedMarks 就没有对应的 mark 了,那我如何取消 bold 呢?本质问题是,现在没有 storedMarks 了,那我如何判断当前输入是处于 bold 下的呢?观察上面的输入,其实也只有在点击 bold 之后,输入第一个字符之后,B就不高亮了,但输入仍为 Bold。所以我们漏了一些东西。

​ 除了 storedMarks, 还有个内容,是 $cursor.marks() 或者使用 $from 也行,因为光标下,这几个内容都位置都是相同的。我们只需要将刚刚的判断条件改为这样:

ts 复制代码
if (!realMarkType.isInSet(tr.storedMarks || $from.marks())) {
  // ...
}

​ 后续输入如果还是 bold 现在就能正常了,接下来就是改造 unsetMark 了,如果是光标状态,storedMarks 或 $from.marks() 中有当前类型的 mark,就需要移除

ts 复制代码
/**
 * 取消 mark
 * 
 * @param view 
 * @param markType 
 */
function unsetMark(view: EditorView, markType: MarkType | string) {
  const { schema, selection, tr } = view.state;
  const { $from, $to, empty } = selection;
  
  const type = getMarkType(markType, schema);

  // 如果处于光标模式,查看是否有 StoredMark,有的话移除(此时如果是 $from.marks() 中有,此 api 也能移除)
  if (empty) {
    if (type.isInSet(tr.storedMarks || $from.marks()) {
      tr.removeStoredMark(type)
    }
  } else {
    tr.removeMark($from.pos, $to.pos, type);
  }

  view.dispatch(tr)

  return true;
}

​ 至此,加粗的功能基本搞定了,但还没完,还有个问题,如果当前我设置了元素不可以使用 marks 呢?就是上面在讲 node 中的 marks 属性的设置。我们给标题加上限制:

ts 复制代码
{
  heading: {
    //...
    // marks 设置为 空字符串,表示禁止所有 mark
    marks: ''
    //...
  }
}

​ 此时你会发现,对 h1 添加加粗,根本就不会增加 strong 标签。所以针对这种类似无法设置 bold 的选区,我们需要将 Bold 按钮直接禁用。通过 node.type.allowsMarkType(markType) 来判断当前 node 是否允许设置 markType,除此之外,因为 mark 是设置给 inline 内容的,还需要通过 node.inlineContent 判断当前节点是否支持 inline 内容。

ts 复制代码
/**
 * 当前是否能设置某个 mark
 * 
 * @param view 
 * @param markType 
 * @returns 
 */
export function canSetMark(view: EditorView, markType: MarkType | string) {
  const { schema, selection, tr } = view.state;

  // 非文本选区,不可以设置 mark
  if (!isTextSelection(selection)) return false;

  const { $cursor, empty, ranges } = selection;
  
  const realMarkType = getMarkType(markType, schema);

  let canSet = false;
  // 先处理 empty
  if (empty) {
    if ($cursor && $cursor.parent.type.allowsMarkType(realMarkType)) {
      canSet = true;
    }
  } else {
    for (let i = 0; !canSet && i < ranges.length; i++) {
      const { $from, $to } = ranges[i];
      tr.doc.nodesBetween($from.pos, $to.pos, (node) => {
        // 只要有能设置的文本,立刻停止遍历
        if (canSet) return false;
        if (node.inlineContent && node.type.allowsMarkType(realMarkType)) {
          canSet = true;
        }
      })
    }
  }
  return canSet;
}

// 添加完之后,为 toolbar 添加设置 disabled 逻辑
{
  label: 'B',
  handler(props) {
    toggleMark(props.view, 'bold')
    props.view.focus();
  },
  update(view, _, menuDom) {
    // 如果不能设置 mark,则为 disabled 状态,分别为 menuDom 添加或取消 disabled,当前 update 中的代码可以做封装,对 italic 之类简单的 mark 可以直接复用
    const disabled = !canSetMark(view, 'bold')
    if (disabled && !menuDom.getAttribute('disabled')) {
      menuDom.setAttribute('disabled', 'true')
      return;
    }
    if (!disabled && menuDom.getAttribute('disabled')) {
      menuDom.removeAttribute('disabled')
    }
    const isActive = isMarkActive(view, 'bold')
    if (isActive && !menuDom.classList.contains('is-active')) {
      menuDom.classList.add('is-active')
    }

    if (!isActive && menuDom.classList.contains('is-active')) {
      menuDom.classList.remove('is-active')
    }
  }
}

​ 我们上面有个调整,在 canSetMark 中没有直接使用 selection 中的 $from$to,而是遍历了 ranges, 其实我们之前那些关于 mark 的代码,也都应该通过 ranges 遍历来获取 from to 的,因为一个 seletion 中可能会有多个 Range 块,只是通常情况下只有一个。这个就自行更改了。

​ 可以看到在标题中,bold 就是置灰状态。

3.4 探索 mark 中的特殊属性

​ 了解了光标系统以及 mark 的一些操作,我们在回到 mark 的定义上,看看 mark 的特殊属性有哪些作用。

inclusive 控制 mark 结尾继续输入是否延续 mark 效果

默认 mark 的 inclusive 是 true, 以加粗为例,默认加粗后,光标放到加粗内容的结尾,继续输入时,后续输入仍然是加粗的,如果改为false,则后面输入就不会继续前面的 mark 状态了。

excludes 设置当前 mark 的互斥 mark

如果当前加粗 Mark excludes 设置了字符串 italic, 则 italic 与 bold 是不能同时被添加的。如果设置为 "_", 则表示当前 bold 与其他所有的 mark 都不能同时添加。例如 行内公式,就需要与其他 mark 互斥。

spanning 是否允许跨越多个节点

​ 默认 spaning 为 true, 即当设置 mark 的时候,如果选区里内容有多个节点,如下 textNode datetime textNode,spaning 为 true 的时候,设置 bold 会用一个 strong 将他们包裹。

​ 如果设置为 false,就会分别给他们添加 strong 标签

4. 小结

​ 本文主要介绍了 Prosemirror 中如何定义 Mark,以及 Mark 的作用,然后重点介绍了 Prosemirror 的选区系统与光标系统,这是一个很重要的内容,很多人在开发中经常搞不清楚 pos 的位置计算问题,在这里,笔者花了较大篇幅详细介绍了光标位置的计算,以及 nodeSize 的计算,在开发中经常要与 pos 打交道,所以如果你是真的需要学些 prosemirror,这一块内容一定要搞清楚。然后重构了之前的 ui,抽象了 toolbar,后续介绍插件的时候,可能会发现我们的抽象层可能与插件中的 view 会异常相似,这并不是抄它的,而是一件自然而然的事,笔者发现后也很惊讶。我们前期写了很多底层 api 的调用,都是为了能更好地打好 prosemirror 的基础,后续如 setMark 这些都是官方封装好的,或者使用 tiptap 的时候,就会发现很多工具都是现成的。

​ 不过还是那句老话,基础不牢,地动山摇,没有掌握足够的工具,面对复杂的场景是没有解决方案的,就像你只会加减乘除,不会微积分,让你去求曲面面积,你只会八脸懵逼,压根就没掌握足够的工具啊,何谈提出想法,何谈提出思路。

​ 好了就这些了,期待与你下次相见!

​ See you next time!

相关推荐
轻口味33 分钟前
【每日学点鸿蒙知识】AVCodec、SmartPerf工具、web组件加载、监听键盘的显示隐藏、Asset Store Kit
前端·华为·harmonyos
alikami35 分钟前
【若依】用 post 请求传 json 格式的数据下载文件
前端·javascript·json
wakangda1 小时前
React Native 集成原生Android功能
javascript·react native·react.js
吃杠碰小鸡1 小时前
lodash常用函数
前端·javascript
emoji1111111 小时前
前端对页面数据进行缓存
开发语言·前端·javascript
泰伦闲鱼1 小时前
nestjs:GET REQUEST 缓存问题
服务器·前端·缓存·node.js·nestjs
m0_748250031 小时前
Web 第一次作业 初探html 使用VSCode工具开发
前端·html
一个处女座的程序猿O(∩_∩)O2 小时前
vue3 如何使用 mounted
前端·javascript·vue.js
m0_748235952 小时前
web复习(三)
前端
User_undefined2 小时前
uniapp Native.js原生arr插件服务发送广播到uniapp页面中
android·javascript·uni-app