ProseMirror 高级 UI 定制:结合 highlight.js 打造代码编辑块

4,226 阅读11分钟

1. Prosemirror 高级 UI 定制

在 ProseMirror 中,我们有多种方式来进行编辑器的高级 UI 定制。这些方法提供了不同层次的灵活性,从简单的 CSS 样式调整到更复杂的节点和标记的自定义。总的来说,ProseMirror 的高级 UI 定制可以分为三种主要的方法:

Schema 中的 toDOM 方法: 这是最基本的定制方式,通过在定义 Schema 时,使用 nodes 或 marks 中的 toDOM 方法,你可以设计节点或标记的 UI 结构。通过 toDOM 方法,可以为节点和标记指定 HTML 结构,然后通过 CSS 修改 nodes 与 marks 的样式。

NodeView 和 MarkView: NodeView 和 MarkView 提供了更高级的定制选项。NodeView 允许你完全掌控节点的渲染方式,并且可以处理用户交互事件,实现更灵活的定制。而 MarkView 则相对较弱,只能支持样式修改,无法像 NodeView 一样支持交互操作,只支持了 dom 与 contentDOM 两个属性,因此 toDOM 中就可以完全满足需求了,它可以不关注。

Decoration 设置样式: 使用 Decoration,你可以在编辑器中的特定位置设置样式。Decoration 可以分为 Inline、Widget 和 Node 三种类型。inline 类型用于为行内内容设置属性,node 类型则专注于为节点增加属性,而 widget 类型用于在页面中插入元素,相比较来说 inline 与 Widget 比较常用,我们本文中也会重点介绍他们。

在本篇文章中,将会以一个代码编辑块实战案例,覆盖 NodeView、inline Decoration 与 Widget Decoration 来探索 Prosemirror 中 UI 的高级定制。

2. Prosemirror 中如何添加代码编辑块

在 Prosemirror 的官方文档中给出了结合 Codemirror 实现的代码块的案例,除了接入 Codemirror,我们也能通过自己实现类似的代码块,但目前例如 tiptap、remirror 等框架提供的实现方案都非常简单,无法支持代码高亮、切换语言、行号等信息。选择这个案例一方面是为了挑战一下实现一个更高完成度的代码块,同时它也覆盖了 NodeView、Decortion.inline、Decoration.widget 以及 Prosemirror 中的插件。我们技术选型使用了 highlight.js,因此还要求我们对 highlight.js 也要有一定的探索,因此他是一个非常好的探索。

2.1 先思考一下实现方案

其实实现方案我还是想了很久,问了半天 chatGPT,也没能得出正确的结论。因为在编辑器中,我们使用 highlight.js 并不是仅仅调用一下他的 highlight API 就完了,相反,你调用之后可能会发现根本不生效。其实在 NodeView 中,它与 toDOM 的相同之处在于,它也有个 dom 与 contentDOM,contentDOM 中是留出给编辑器输入用的,我们输入的代码就在这里,每次输入,编辑器都会将我们输入的纯文本替换到 conentDOM 中,这就导致简单实用 highlight 是修改内容是行不通的。

但好在 Prosemirror 支持一种叫做 Decoration 的东西,可以在编辑器上添加一些样式,Decoration.inline 可以在指定位置范围,给这段文本添加一些属性,当然,添加属性是会自动使用 span 包裹的,先来看看效果

// 先创建一个固定的文本
var doc = schema.nodeFromJSON({
  "type": "doc",
  "content": [
      {
          "type": "block_tile",
          "content": [
              {
                  "type": "paragraph",
                  "content": [
                      {
                          "type": "text",
                          "text": "123456789"
                      }
                  ]
              }
          ]
      }
  ]
})
// 加入到 state 中
const editorState = EditorState.create({
  schema,
  plugins: [
    //...
  ],
  doc
})

const editorView = new EditorView(editorRoot, {
  state: editorState,
  nodeViews: {
    code_block: codeBlockViewConstructor
  },
  // editor View 中增加一个 decorations,通过  Decoration.inline 指定在位置 从 5 -> 10 的文本上,添加 style 样式,为红色
  decorations(state) {
    const decoration = Decoration.inline(5,10, { style: 'color: red' });
    // 返回的 decoration 必须是个 DecorationSet
    return DecorationSet.create(state.doc, [decoration]);
  }
})

​ 最终它的效果就是在 5 -> 10 的位置,内容颜色变为红色,红色的实现是通过包裹了一层 span 来的。

image-20231022032821082

其实想想之前的 marks,是有点类似的,不过这种 decoration 既然叫做装饰,就与 mark (标记) 是有区别的,标记听起来就会更稳定一些,装饰就不行了,我们可以在刚刚的 decorations 中随便打印输出一点东西,会发现每次有 tr 提交,decorations 都会执行,哪怕是移动光标,所以装饰就是装饰,一点不稳定就是他。

Kapture 2023-10-22 at 03.32.44

有个好处是,decoration 它并不影响我们的 node 结构,看过之前讲解光标位置系统的文章就能知道,prosemirror 的结构是由 node 组成的 schema 结构严格控制的,decoration 对于 prosemirror 文档来说,即便是添加了 dom 的标签、属性,压根就不影响位置计算,也不影响 node.textContent,node.textContent 该是纯文本还是纯文本。装饰就相当于浮于表面,不影响本质。

因此,要想实现代码高亮,我们就需要先拿到 code_block(即将创建用于输入代码的块)的纯文本内容,交给 highlight,js 解析,解析后,我们需要获取每个 token 的开始结束位置,以及他们的 class 类名,将他们一个一个全部通过 Decoration.inline 的方式添加到对应位置,每次修改都执行这个过程,这样就达到了目的。

2.2 开始整活,增加 node 定义

code 的结构在 prosemirror 中算是比较特殊的,我们先来定义他的 NodeSpec 描述信息:

export const codeBlock: NodeSpec = {
  // 内容只支持纯文本
  content: 'text*',
  // 归属于 block 分组
  group: 'block',
  // mark 为空字符串,拒绝添加任何mark
  marks: '',
  // 标记 code 为 true, 内部一些处理会对 节点内容中包含 code 的节点进行特殊处理
  code: true,
  // defining 为true, 之前讲过它,全选它的内容,粘贴文本,不会直接把 code 的标签给替掉
  defining: true,
  draggable: false,
  selectable: true,

  // attrs 增加语言,主题,行号配置(主题本次不实现)
  attrs: {
    language: {
      default: 'plaintext'
    },
    theme: {
      default: 'dark'
    },
    showLineNumber: {
      default: true
    },
  },

  toDOM(node) {
    return ['pre', {
      'data-language': node.attrs.language,
      'data-theme': node.attrs.theme,
      'data-show-line-number': node.attrs.showLineNumber,
      'data-node-type': 'code_block',
    }, ['code', 0]]
  },

  parseDOM: [
    {
      tag: 'pre',
      preserveWhitespace: 'full',
      getAttrs(node) {
        const domNode = node as HTMLElement;
        return {
          language: domNode.getAttribute('data-language'),
          theme: domNode.getAttribute('data-theme'),
          showLineNumber: domNode.getAttribute('data-show-line-number')
        }
      }
    }
  ]
}

定义完之后,将其添加到 schema 中,再定义一个添加 code 的命令

export const createCodeBlockCmd: Command = (state, dispatch, view) => {
  // 为了后续方便,每次创新新的 code block,预览就使用上次使用的 langguage,上次的 language 后面会记录在 schema.cached 中
  const lastLanguage = state.schema.cached.lastLanguage || 'plaintext';

  const { block_tile, code_block } = state.schema.nodes;
  const codeBlockNode = block_tile.create({}, code_block.create({ language: lastLanguage }));

  let tr = state.tr;
  tr.replaceSelectionWith(codeBlockNode);
  tr.scrollIntoView();

  if (dispatch) {
    dispatch(tr)
    return true
  }
  return false;
}

// 然后增加一个按钮,调用命令。
{
  label: '添加代码块',
  handler: ({ state, dispatch, view }) => {
    createCodeBlockCmd(state, dispatch, view)
    setTimeout(() => {
      view.focus()
    })
  }
}

再随便给 code 添加一些样式,加完就能够正常添加一个节点了

image-20231022035229166

2.2 通过 NodeView 实现 Code Block 的高级定制

当前的代码块是不能满足我们需求的,我们需要有个 header,里面有个按钮能选择是哪种语言,也能选择是否展示行号,但通过 toDOM 添加是比较费劲的,因为选择语言要涉及到修改 node 的 attrs,toDOM 中完全无法实现。NodeView 在 Prosemirror 中是个接口,我们需要实现一下这个接口。其中主要还是实现 dom 与 nodeDOM,除此之外,还有个 update 是 toDOM 中没有的,在 NodeView 中,我们可以很轻松获取到 editorView,以及每次更新后,可以及时了解到更新,并获取到当前更新的 node 实例

export class CodeBlockView implements NodeView {
  name = 'block_code';

  // view 与 getPos 是我们自己定义的属性,保存一下 editor 与 getPos 方便使用
  private view: EditorView;
  private getPos: () => number | undefined;

  // 在 view 中配置 nodeView 时,每个 nodeView 对应的都是个都是个函数,类型为 NodeViewConstructor
  // 里面的参数可以获取到 node,view,getPos 等信息,这里 node 是当前初始化时候 node 节点对应的实例
  // 后续每次更新,这个 node 就是不能用的,因为 prosemirror 每次更新都是 immutable 的,每次都是新数据
  // view 就不说了,getPos 可以获取到当前 node 在文档中的位置
  constructor(...args: Parameters<NodeViewConstructor>) {
    const [node, view, getPos] = args;
    
    this.view = view;
    this.getPos = getPos;
    this.node = node;

    // renderUI 就是根据 node 的一些 attrs,生成一个 dom 与 contentDOM,这个方法也是我们自己定义的
    // 后续接入 vue、react、svelte 等,这里的 可以用框架实现,反正最后把组件绑定到 this.dom 上,如果需要里面能输入内容,
    // 就需要一个 contentDOM 专门接收浏览器输入的内容的
    this.renderUI(node)

  }
  
  dom!: Node;
  contentDOM!: HTMLElement;
  node!: PMNode;

  // 最后就是 update 了,这个跟我们之前写的插件的 PluginView 有点类似,都是在编辑器内容更新的时候,都会触发这里的 update
  update(...params: Parameters<Required<NodeView>['update']>) {
    const [node] = params;
    this.node = node;
    if (node.type.name !== 'code_block') {
      return false;
    }

    this.updateUI(node);

    return true;
  };

  /**
   * 渲染 ui, 这里具体就是通过原始的 dom 操作拼 ui 呢
   * @param node 
   */
  private renderUI(node: PMNode) {
    // pre-wrapper
    this.dom = crel('pre', {
      'data-language': node.attrs.language,
      'data-theme': node.attrs.theme,
      'data-show-line-number': node.attrs.showLineNumber,
      'data-node-type': 'code_block',
    })

    // code-meanu
    const menuContainer = crel('div', 
      {
        class: 'code-block-memu-container',
      },
      crel('div', 
        {
          class: 'code-block-menu',
        }, 
        crel('select', {
          class: 'code-name-select',
          onchange: (event: Event) => {
            const { state, dispatch } = this.view;
            const language = (event.target as HTMLSelectElement).value;
            const pos = this.getPos();
            this.view.state.schema.cached.lastLanguage = language;
            if (pos) {
              const tr = state.tr.setNodeAttribute(pos, 'language', language);
              dispatch(tr);
              setTimeout(() => this.view.focus(), 16);
            }
          }
        }, ['plaintext','javascript', 'html', 'markdown', 'typescript', 'python', 'java'].map(item => crel('option', { value: item, selected: item === node.attrs.language }, item))), 
        crel('div', {
          class: 'code-menu-right'
        }, 
          crel('select', 
            { 
              class: 'show-line-number-select',
              onchange: (event: Event) => {
                const { state, dispatch } = this.view;
                const showLineNumber = (event.target as HTMLSelectElement).value === 'true';
                const pos = this.getPos();
                if (pos) {
                  const tr = state.tr.setNodeAttribute(pos, 'showLineNumber', showLineNumber);
                  dispatch(tr);
                  setTimeout(() => this.view.focus(), 16)
                }
              }
            }, 
            [{value: 'true', label: '展示行号'},{value: 'false', label: '隐藏行号'}].map(item => (
              crel('option', {
                selected: item.value === node.attrs.showLineNumber.toString(),
                value: item.value
                
              }, item.label)
            ))
          ),
          crel('button', {
            class: 'copy-btn',
            onmousedown: () => {
              navigator.clipboard.writeText(this.node.textContent).then(() => {
                alert("copied!")
              })
            }
          }, 'copy')
        )
      )
    )

    // content dom
    const code = crel('code', {
      class: `code-block language-typescript ${node.attrs.showLineNumber ? 'show-line-number' : ''}`,
      lang: node.attrs.language
    })

    this.contentDOM = code;

    this.dom.appendChild(menuContainer)
    this.dom.appendChild(code)
  }

  /**
   * 更新 ui
   * @param node 
   */
  private updateUI(node: PMNode) {
    const {showLineNumber, language} = node.attrs;
    const showLineNumberClass = 'show-line-number'
    if (showLineNumber && !this.contentDOM.classList.contains(showLineNumberClass)) {
      this.contentDOM.classList.add(showLineNumberClass)
    }
    if (!showLineNumber && this.contentDOM.classList.contains(showLineNumberClass)) {
      this.contentDOM.classList.remove(showLineNumberClass)
    }

    this.contentDOM.dataset.lang = language;
  }
}

export const codeBlockViewConstructor: NodeViewConstructor = (...args) => new CodeBlockView(...args)

const editorView = new EditorView(editorRoot, {
  state: editorState,
  // 最后在 editorView 中添加 code_block 对应的 view
  nodeViews: {
    code_block: codeBlockViewConstructor
  },
})

在定义了 nodeView 之后,nodeView 会覆盖 toDOM 的展示,我们可以来看看效果

image-20231022041205926

在 nodeView 中,我们例如点击左边的语言切换,点击右边的行号展示,本质都应该修改 node 的 attrs,上面代码有点多,这里简单描述下,我们通过之前获取到的 getPos 可以获取到 node 此时具体的位置,我们每次修改文本后,node 都会变的,它对应的位置也会变,getPos 是个函数,它获取的其实就是变化后的位置了,可以放心使用。我们自己在 nodeView 中保存了 node 实例,每次 update 都重新保存一遍,为的就是保证我们可以实时用到最新可用的 node 而不是过期的 node。修改 node 的 attr 是通过 tr.setNodeAttribute 改的,例如:state.tr.setNodeAttribute(pos, 'language', language) ,到这里其实就把一个带功能的一个视图定制好了。

2.3 代码高亮的实现

对于这个高亮,其实一下子并不好入手,我们可以从简单到复杂慢慢实现,目前具体怎么做不知道,只知道用插件里面的 decorations 做,那就可以搭好架子

interface HighlightCodePluginState {
  decorations: DecorationSet
}

export const highlightCodePluginKey = new PluginKey<HighlightCodePluginState>('highlight-code');

/**
 * highlight code plugin
 * 
 * @returns 
 */
export function highlightCodePlugin() {
  // 专门计算生成 decoration 的函数,后面再细看
  function getDecs(doc: PMNode): Decoration[] {
    let decorations: Decoration[] = [];

    return decorations;
  }

  // 创建一个插件,回顾上篇文章,还是使用 state,在里面保存当前的 decorations
  return new Plugin({
    key: highlightCodePluginKey,
    state: {
      init(_, instance) {   
        const decorations = getDecs(instance.doc)
        return {
          decorations: DecorationSet.create(instance.doc, decorations)
        }
      },
      apply(tr, data, oldState, newState) {
        // 文档没变就不重新计算获取 decoration,避免性能浪费
        if (!tr.docChanged) return data;

        const decorations = getDecs(newState.doc)
        return {
          decorations: DecorationSet.create(tr.doc, decorations)
        }
      }
    },
    props: {
      // 这里的 decorations 与开篇在 editorView 中的一致,不过我们这里把 DecorationSet 的创建都放在 state 中了,
      // 不然会导致每次 tr 一触发,这里就重新生成 DecorationSet,可能还会导致报错
      decorations(state) {
        const pluginState = highlightCodePluginKey.getState(state);

        return pluginState?.decorations
      },
    }
  })
}

架子搭好之后,主要的细节就是实现代码高亮部分,高亮我们需要将所有的 code_block 都找到,获取到里面的文本内容,交给 highlight 解析,生成 token 后我们再看怎么做,先实现一下获取所有的 code_block

import type { NodeType, Node as PMNode } from "prosemirror-model";

export interface NodeWithPos {
  node: PMNode;
  pos: number;
}

/**
 * 获取所有指定类型的 node
 * 
 * @param doc 
 * @param type 
 * @returns 
 */
export function findNodesOfType(doc: PMNode, type: string | string[] | NodeType | NodeType[]) {
  const schema = doc.type.schema;

  const tempTypes: string[] | NodeType[] = Array.isArray(type) ? type : [type] as (string[] | NodeType[])
  const types = tempTypes
    .map(item => typeof item === 'string' ? schema.nodes[item] : item)
    .filter(item => item)

  const nodes: NodeWithPos[] = [];

  doc.descendants((node, pos) => {
    if (types.includes(node.type)) {
      nodes.push({
        node,
        pos
      })
    }
  })
  
  return nodes;
}

function getDecs(doc: PMNode): Decoration[] {
    if (!doc || !doc.nodeSize) {
      return []
    }
  	// 获取到 文档中所有的 code_block
    const blocks = findNodesOfType(doc, 'code_block');
    let decorations: Decoration[] = [];
    
 		// 遍历生成 decorations
    blocks.forEach(block => {
      let language: string = block.node.attrs.language;

      if (language && !hljs.getLanguage(language)) {
        language = 'plaintext'
      }
      // 拿到具体对应的语言,通过 hljs 解析, 这里语言可以先写死 typescript,我调试时候是写死的
      const highlightResult = language 
        ? hljs.highlight(block.node.textContent, { language })
        : hljs.highlightAuto(block.node.textContent)

    return decorations;
  }

现在获取了文档中所有的 code_block,并且通过 hljs 把他们的内容都进行解析了,解析完后该怎么处理呢?我们需要打印看一下解析后的结果:

image-20231022043353358

在上面的图中,其实我们需要用到的不是什么 value,而是下面的 _emitter,其中有个 stack 栈,这个栈就是 hightlight 最终将代码生成 html 高亮的每个单元,scope 最终会替换成一个 span 的 class,例如上面的 keyword,最终这种会被替换成 <span class="hljs-keyword",对于那种普通的文本,就还是文本的样子。因为只要你观察了 hightlight.js 的官网生成的例子,它就是对一些特殊的语法,进行添加了 span,其他内容还是文本,那我们就可以判断,这里面只要是对象的,都是要转为 span 的,并且 scope 是 class, 通过 . 分割,如上面 title.function,表示最终转为 html 为 <span class="hljs-title hljs-function">,前面有个 hljs- 的前缀,可以在 _emitter.options 中获取到。

目前就差遍历这个栈了,我们的所有文本被打散成为一个个小片段,我们需要按顺序遍历,恰好 highlight.js 的 _emitter 中有个 walk 遍历的函数,它的便利就是顺序的。因为他本身也要根据这个栈生成结果中的那个 value 嘛,那不就是带标签的。在翻看 highlight 源码后,发现它渲染为 HTML 就是写一个渲染器,里面通过 walk 遍历栈的时候,遇到特殊词法,比如 keyword,他就会先生成一个 开始标签 <span calss="hljs-keyword">,然后触发 openNode,把上面栈中对应的 token 传进来,然后到 keyword 里面也不能再拆了,就把 <span calss="hljs-keyword">keyword 拼到一起,触发 addText,把文本传进来,然后一个 token 结束,它就再拼结束标签 <span calss="hljs-keyword">keyword</span>,同时触发 closeNode。


interface Renderer {
  
  addText: (text: string) => void;
  openNode: (node: DataNode) => void;
  closeNode: (node: DataNode) => void;
}

那这样就很简单了,我们先明确自己要什么?我们需要的是这样的信息:

interface RenderInfo {
  from: number;
  to: number;
  classNames: string[];
  scope: string;
}

我们最终是需要在编辑器中,找到对应的 token 前后的位置,通过 Decoration,inline 给 token 添加 span 标签的,我们不能直接用 hljs 解析出来的带标签的字符串,而是要自己加的。不过按照前面分析,我们在它进行 openNode 与 closeNode 的时候,就能分析出来,是在什么位置给文本添加开始结束标签的,这不就是我们需要的 fromto 嘛~

没明白??在演示一遍,我记录一下字符串位置,从 pos=0 开始遍历,到 <span calss="hljs-keyword"> 的时候,触发 openNode ,我们就生成一个 RenderInfo,记录 from 是 0,然后 <span calss="hljs-keyword">keyword 这一步拼了 keyword 会触发 addText,我们拿到 text 文本后 pos = pos + text.length,可以知道当前经过了几个文本了,再然后到 <span calss="hljs-keyword">keyword</span> ,触发 closeNode,这时候,我们就可以更新我们的 RenderInfo 的 to 了,就是刚刚 加上文本的长度的地方。如果里面有嵌套,我们应该就的自己也实现一个栈来保存了,有点像面试常考的括号匹配。

class ProseMirrorRenderer implements Renderer{
  // 当前位置
  private currentPos: number;
  // 最终匹配好的所有 renderInfo 
  private finishedRenderInfos: RenderInfo[] = [];
  // 正在进行加了 from 没有加 to 的这些,会一次入栈
  private trackingRenderInfoStack: RenderInfo[] = [];
  // 这里是 hljs-,是从 _emitter.options.classPrefix 获取的
  private classPrefix: string;
  
  constructor(tree: TokenTreeEmitter, blockStartPos: number) {
    // 这里实例化的时候直接记录初始位置,这里开始的位置是 code_block 开始位置 + 1,原因是还是之前的 node 坐标系统,
    // 具体的文本是 <code_block>keyword 这样的,在 code_block 标签后面开始的,code_block开始位置是标签之前
    this.currentPos = blockStartPos + 1;
    this.classPrefix = tree.options.classPrefix;

    // 直接开始遍历
    tree.walk(this)
  }

  // add Text 就开始更新位置
  addText(text: string){
    if (text) {
      this.currentPos += text.length
    }
  }

  // open 时候就创建 render Info,并入栈
  openNode(node: DataNode){
    // node.scope is className
    if (!node.scope) return;

    // create new render info, which corresponds to HTML open tag.
    const renderInfo = this.newRenderInfo({
      from: this.currentPos,
      classNames: node.scope.split('.').filter(item => item).map(item => this.classPrefix + item),
      scope: node.scope
    });
    
    // push tracking stack
    this.trackingRenderInfoStack.push(renderInfo)
  }

  // close 就出栈补充 to 信息,补充完丢带完成的数组中
  closeNode(node: DataNode){
    if (!node.scope) return;
    const renderInfo = this.trackingRenderInfoStack.pop()
    if (!renderInfo) throw new Error("[highlight-code-plugin-error]: Cannot close node!")

    if (node.scope !== renderInfo.scope) throw new Error("[highlight-code-plugin-error]: Matching error!")

    renderInfo.to = this.currentPos;

    // finish a render info, which corresponds to html close tag.
    this.finishedRenderInfos.push(renderInfo)
  }

  // 快捷的创建 renderINfo 的辅助方法
  newRenderInfo(info: Partial<RenderInfo>): RenderInfo {
    return {
      from: this.currentPos,
      to: -1,
      classNames: [],
      scope: '',
      ...info
    }
  }

  // 获取 value
  get value() {
    return this.finishedRenderInfos;
  }
  
}

这样就得到一系列 token 的开始结束信息,我们看看具体长什么样

function getDecs(doc: PMNode): Decoration[] {
  // ...
  const emmiter = highlightResult._emitter as TokenTreeEmitter;
  const renderer = new ProseMirrorRenderer(emmiter, block.pos);

  console.log(renderer.value)
}

image-20231022050905996

到这里我们可以看到就是 from,to 以及对应 token 应该是什么样子的类名,有了这个信息,那我们创建 inline 类型的 Decoration 不是手拿把掐。

function getDecs(doc: PMNode): Decoration[] {
    //...
    if (renderer.value.length) {
      // 直接便利,根据 from, to, 然后添加 className,
      const blockDecorations = renderer.value.map(renderInfo => Decoration.inline(renderInfo.from, renderInfo.to, {
        class: renderInfo.classNames.join(' '),
      }))

      decorations = decorations.concat(blockDecorations);
    }
  })

    return decorations;
  }

这样就可以了,不过记得要把插件在 new EditorState 的时候注册一下,然后我们就能获得高亮了,当然高亮是需要自己手动引入 hljs 的主题 css 的,自己找一个就行了。这时候试试切换语言应该也是正常的,自己输入内容也可以。

image-20231022051407533

2.4 代码行号展示

上面代码高亮用的是 inline 类型的 decoration,它的特点就是给已经存在的 inline 内容包裹一层 span,然后加样式,每次 tr 触发时候,都重新进行计算。但目前如果要上行号,就不好处理了,inline 无法满足,因为行号是纯新增的部分,而不是对已有的内容增加属性。此时就需要使用 widget 类型的 decoration。这个的思路也是比较简单的,拿到代码内容根据 \n 拆分,看有多少行,就有多少个 widget,然后创建 widget

function createLineNumberDecorations(block: NodeWithPos) {
  
   // 拿到 代码文本,然后根据 \n 切割
    const textContent = block.node.textContent;

    const lineInfos = textContent.split('\n');
    
 		// 开始计算位置
    let currentPos = block.pos + 1;

   // 遍历所有行,生成 widget
    const decorations: Decoration[] = lineInfos.map((item, index) => {
      const span = crelt('span', {class: 'line-number', line: `${index + 1}`}, "\u200B");

      // widget 只有一个 pos,也就是它需要被添加到的地方,我们计算的位置刚好都是每行的最开头那个位置,然后添加的内容是上面创建的 span
      const decoration = Decoration.widget(currentPos, (view) => span, {
        // side -1 表示在添加的内容在光标左侧
        side: -1,
        // 当前内容不被选中
        ignoreSelection: true,
        // 销毁时候记得移除,否则会出现异常
        destroy() {
          span.remove()
        }
      })

      // 更新位置
      currentPos += item.length + 1;

      return decoration
    });

    return decorations
  }

// 最后再便利 block 的时候,给 行号的 decoration 加上
function getDecs(doc: PMNode): Decoration[] {
  //...
  // show line number
  if (block.node.attrs.showLineNumber) {
    const lineNumberDecorations = createLineNumberDecorations(block);

    decorations = decorations.concat(lineNumberDecorations);
  }
}

看看效果:

Kapture 2023-10-22 at 05.25.36

需要注意的是,如果添加的widget里面 span 内容是空的,移动键盘左右键以及换行就会出问题,如果你真的不需要内容,可以像上面一样,添加一个\u200B 零宽字符,这个问题折磨了笔者一整个下午。

2.5 细节完善

目前我们在 code_block 中,ctrl + a 全选是会有问题的,他会选中全部文档,我们的期望是直选中当前 node 节点里面的内容,我们可以写个 Command 来覆盖一下 ctrl+a 的行为

/**
 * select all in code_block just select code inner content
 * 
 * @param state 
 * @param dispatch 
 * @returns 
 */
export const selectAllCodeCmd:Command = (state, dispatch) => {
  const { selection, tr } = state;

  const codeBlock = findParentNode(node => node.type.name === 'code_block')(selection);

  if (!codeBlock || !dispatch) return false;

  tr.setSelection(TextSelection.create(tr.doc, codeBlock.pos + 1, codeBlock.pos + codeBlock.node.nodeSize - 1))
  
  dispatch(tr);
  
  return true;
} 

// 在应用的时候,通过 `Mod-a`, 覆盖掉默认全选,但是在别的地方全选还是要正常的,我们需要通过 chainCommands,优先把当前的放进去,不然可能默认的拦截成功,就不会执行我们的命令了。
// 除了 ctrl+a,我们还需要把之前插入段落的优化一下,因为现在只要回车就插入段落,在 code 中不太对的,所以,也用 chainCommands 把之前的 enter 行为补上。同时要在 insertParagraphCommand 插入段落中判断如果是处于 blockquote 或者 code_block 就直接返回 false,不进行拦截。
keymap({
    ...baseKeymap,
    'Mod-a': chainCommands(selectAllCodeCmd, baseKeymap['Mod-a']),
    Enter: chainCommands(insertParagraphCommand, baseKeymap['Enter'])
  }),
  
// 需要拦截 'code_block', 'blockquote',他俩不走插入行的逻辑, 而是走默认的逻辑
export const insertParagraphCommand: Command = (state, dispatch) => {
  const { tr, schema } = state;
  const { block_tile, paragraph } = schema.nodes;

  const node = findParentNode(node => ['code_block', 'blockquote'].includes(node.type.name))(state.selection);

  if (node) return false;
 	// ...
}

到此,我们这个功能也完善差不多了。

3. 小结

本文主要带领大家一起探索了 Prosemirror 中关于 NodeView、Deoration 的概念,一起了解了 Prosemirror 中有哪些方式可以更新视图。通过这个代码编辑块的实战案例,也见识到了 prosemirror 其实不光是编辑器,对于真实的业务,我们面临的每一个定制组件,可能都是一个专门的领域,这个代码高亮的稍微简单点,需要分析 highlight.js 的源码,对于表格、或者同构表、思维导图等,可能都是一个纵深的发展方向,特别现在块文档编辑器的高速发展,很多业务都要集成到文档上来,编辑器就变成了一个不仅仅局限于传统类似 word 富文本的一个超级产品,难度也会加上来。

到目前为止,其实我们 prosemirror 的基础篇就会告一段落了,如果从第一篇看到本篇,差不多 prosemirror 也就入门了。

展望

当然,此处不是煽情的地方,我的编辑器系列,也远没有结束,后面会专注于 prosemirror 的MVC 中的Controller上,可能会有点枯燥无聊,会是一些源码解析什么的,学完 prosemiror 的基础,后续的操作其实就可以上 tiptap 操作了。除此之外,可能还会找些些实战场景,后面把当前的 demo 用 tiptap 重构优化一遍,nodeview 也从 dom 原生迁移到框架,了解一些 web-components 库.

就这些了,期望下次相见!

See you next time!