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

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 包裹的,先来看看效果

ts 复制代码
// 先创建一个固定的文本
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 来的。

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

有个好处是,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 描述信息:

ts 复制代码
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 的命令

ts 复制代码
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 添加一些样式,加完就能够正常添加一个节点了

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

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

ts 复制代码
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 的展示,我们可以来看看效果

在 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 做,那就可以搭好架子

ts 复制代码
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

ts 复制代码
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 把他们的内容都进行解析了,解析完后该怎么处理呢?我们需要打印看一下解析后的结果:

在上面的图中,其实我们需要用到的不是什么 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。

ts 复制代码
interface Renderer {
  
  addText: (text: string) => void;
  openNode: (node: DataNode) => void;
  closeNode: (node: DataNode) => void;
}

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

ts 复制代码
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 了,就是刚刚 加上文本的长度的地方。如果里面有嵌套,我们应该就的自己也实现一个栈来保存了,有点像面试常考的括号匹配。

ts 复制代码
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 的开始结束信息,我们看看具体长什么样

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

  console.log(renderer.value)
}

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

ts 复制代码
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 的,自己找一个就行了。这时候试试切换语言应该也是正常的,自己输入内容也可以。

2.4 代码行号展示

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

ts 复制代码
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);
  }
}

看看效果:

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

2.5 细节完善

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

ts 复制代码
/**
 * 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!

相关推荐
masa01019 分钟前
JavaScript--JavaScript基础
开发语言·javascript
让开,我要吃人了2 小时前
HarmonyOS开发实战(5.0)实现二楼上划进入首页效果详解
前端·华为·程序员·移动开发·harmonyos·鸿蒙·鸿蒙系统
everyStudy3 小时前
前端五种排序
前端·算法·排序算法
甜兒.4 小时前
鸿蒙小技巧
前端·华为·typescript·harmonyos
她似晚风般温柔7897 小时前
Uniapp + Vue3 + Vite +Uview + Pinia 分商家实现购物车功能(最新附源码保姆级)
开发语言·javascript·uni-app
Jiaberrr8 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
everyStudy8 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白8 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、8 小时前
Web Worker 简单使用
前端
web_learning_3218 小时前
信息收集常用指令
前端·搜索引擎