likes
comments
collection
share

用 ProseMirror 实现一个富文本编辑器[Demo]

作者站长头像
站长
· 阅读数 68

前言

在我写自己的项目中,需要使用富文本编辑器来编辑文本,之前对于文本编辑的了解只停留在 textarea 上,后来了解到基于 contentEditable 属性的 ProseMirror,记录一下 prosemirror 的学习过程并写一个 demo。

项目仓库:github.com/ljq0226/my-…

部署地址:my-prosemirror-editor.netlify.app/

用 ProseMirror  实现一个富文本编辑器[Demo]

介绍

ProseMirror 是一个基于 ContentEditable 的所见即所得 HTML 编辑器,功能强大,支持协作编辑和自定义文档模式 prosemirror 库由多个单独的模块组成,它的优点:模块化,可扩展,可插拔,缺点:不是开箱即用,需要基于 prosemirror 生态拓展开发(如 TipTap)。

核心模块:

prosemirror 核心有四个模块:

  • prosemirror-model:定义编辑器的文档模型,用来描述编辑器内容的数据结构。
  • prosemirror-state:描述编辑器整体状态,包括文档数据、选择等。
  • prosemirror-view:UI组件,用于将编辑器状态展现为可编辑的元素,处理用户交互。
  • prosemirror-transform:修改文档的事务方法。 用 ProseMirror  实现一个富文本编辑器[Demo] 用 ProseMirror  实现一个富文本编辑器[Demo]

Demo

构建项目

使用 vite 搭建项目: pnpm create vite my-prosemirror --template react-ts

cd my-prosemirror
pnpm install

在这我们使用 Tailwind CSS 写样式,具体配置见官网

//App.tsx
function App() {
  return (
    <div className="h-screen w-screen p-[200px] bg-slate-300">
      <h1 className="text-center text-2xl">My-ProseMirror-Editor</h1>
      <div className="h-[800px] bg-white w-full">

      </div>
    </div>
  )
}
export default App

pnpm run dev 启动后效果如下:

用 ProseMirror  实现一个富文本编辑器[Demo]

代码仓库:commit-0

定义 Schema

在 ProseMirror 中,Schema(模式)定义了编辑器中可用的节点(nodes)和标记(marks)的结构和行为。模式描述了文档的结构以及每个节点和标记的属性、解析规则和渲染规则。

import { Schema } from "prosemirror-model";

export const schema = new Schema({
  nodes: {
    //顶级节点,表示整个文档。它可以包含多个 block 类型的子节点。
    doc: {
      content: "block+"
    },
    //段落节点,用于表示段落文本。它可以包含多个inline类型的子节点。
    //具有align 属性,用于指定对齐方式。parseDOM定义了如何从 DOM 元素解析为节点
    // ,toDOM定义了如何将节点渲染为 DOM 元素。
    paragraph: {
      content: "inline*",
      group: "block",
      attrs: {
        align: { default: "left" }
      },
      parseDOM: [
        {
          tag: "p",
          getAttrs(dom) {
            if (typeof dom === "string") {
              return false;
            }
            return {
              align: dom.style.textAlign || "left"
            };
          }
        }
      ],
      toDOM(node) {
        const { align } = node.attrs;
        if (!align || align === "left") {
          return ["p", 0];
        }
        return ["p", { style: `text-align: ${align}` }, 0];
      }
    },
    //文本节点,用于表示文本内容。它是inline类型的节点。
    text: {
      group: "inline"
    }
  },
  //标记:描述每个元素的解析规则和渲染规则
  marks: {
    em: {
      parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }],
      toDOM() {
        return ["em", 0];
      }
    },

    strong: {
      parseDOM: [
        { tag: "strong" },
        {
          tag: "b",
          getAttrs: (node: string | HTMLElement) =>
            typeof node !== "string" &&
            node.style.fontWeight !== "normal" &&
            null
        },
        {
          style: "font-weight",
          getAttrs: (value: string | HTMLElement) =>
            typeof value === "string" &&
            /^(bold(er)?|[5-9]\d{2,})$/.test(value) &&
            null
        }
      ],
      toDOM() {
        return ["strong", 0];
      }
    },
    underline: {
      parseDOM: [{ tag: "u" }],
      toDOM() {
        return ["u", 0];
      }
    },
  }
});

Nodes(节点):

  • doc:顶级节点,表示整个文档。它可以包含多个 block 类型的子节点。
  • paragraph:段落节点,用于表示段落文本。它可以包含多个 inline 类型的子节点。具有 align 属性,用于指定对齐方式。parseDOM 定义了如何从 DOM 元素解析为节点,toDOM 定义了如何将节点渲染为 DOM 元素。
  • text:文本节点,用于表示文本内容。它是 inline 类型的节点。

Marks(标记):

  • strong:加粗标记,用于表示加粗文本。parseDOM 定义了如何从 DOM 元素解析为标记,toDOM 定义了如何将标记渲染为 DOM 元素。
  • underline:下划线标记,用于表示下划线文本。parseDOM 定义了如何从 DOM 元素解析为标记,toDOM 定义了如何将标记渲染为 DOM 元素。

通过定义节点和标记,并指定它们的属性、解析规则和渲染规则,可以在 ProseMirror 编辑器中创建具有丰富格式和结构的文档。

Editor 组件

首先我们先创建一个 Editor 组件,并引入到 App.tsx 中:

export type Props = {
  initialHtml: string;
  onChangeHtml: (html: string) => void;
};

const Editor = ({ initialHtml, onChangeHtml }: Props) => {

  return (
    <div className="relative">
      <div/>
    </div>
  );
}
export default Editor

此时,页面并没有任何效果,因为我们还没有创建 EditorView,步骤如下:

  • 安装依赖 pnpm install prosemirror-model prosemirror-state prosemirror-view prosemirror-keymap prosemirror-history prosemirror-commands

  • createDoc

根据给定的 HTML 字符串和 ProseMirrorschema 创建一个 Doc类型的节点。

import { Schema, Node, DOMParser } from "prosemirror-model";

const createDoc = <T extends Schema>(html: string, pmSchema: T) => {
  const element = document.createElement("div");
  element.innerHTML = html;
  return DOMParser.fromSchema(pmSchema).parse(element);
};
  • createState

该函数的作用是创建一个 ProseMirror 的编辑器状态对象。它使用指定的模式和插件来配置编辑器状态,并可以选择设置初始文档:

  1. 使用 EditorState.create 方法创建一个新的编辑器状态对象。
  2. 在 create 方法的配置对象中,指定 doc 选项为 options.doc,用于设置编辑器的初始文档。
  3. 指定 schema 选项为传入的 pmSchema,以定义编辑器的模式。
  4. 指定 plugins 选项为一组插件,用于扩展编辑器的功能。这里使用了以下插件:
    • history():用于支持撤销和重做操作。
    • keymap({...}):用于定义键盘快捷键映射,以便对编辑器进行格式化和操作。
    • baseKeymap:提供了一组基本的键盘快捷键映射,例如回车和删除。
  5. 返回创建的编辑器状态对象。
import { Schema, Node, DOMParser } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { undo, redo, history } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { baseKeymap, toggleMark } from "prosemirror-commands";

const createPmState = <T extends Schema>(
  pmSchema: T,
  options: { doc?: Node } = {}
) => {
  return EditorState.create({
    doc: options.doc,
    schema: pmSchema,
    plugins: [
      history(),
      keymap({
        "Mod-z": undo,
        "Mod-y": redo,
        "Mod-Shift-z": redo
      }),
      keymap({
        "Mod-b": toggleMark(pmSchema.marks.strong),
        "Mod-i": toggleMark(pmSchema.marks.em),
        "Mod-u": toggleMark(pmSchema.marks.underline)
      }),
      keymap({
        Enter: baseKeymap["Enter"],
        Backspace: baseKeymap["Backspace"]
      }),
    ]
  });
};
  • 绑定EditorView
//Editor.tsx
import { useEffect, useRef} from "react";
import { EditorView } from "prosemirror-view";
import { schema } from "../schema";
...

const Editor = ({ initialHtml, onChangeHtml }: Props) => {
 const elContentRef = useRef<HTMLDivElement | null>(null);
  const editorViewRef = useRef<EditorView>();

  useEffect(() => {
    //1.创建 doc 对象
    const doc = createDoc(initialHtml, schema);
    //2.创建 prosemirror state
    const state = createPmState(schema, { doc });
	//3.创建 EditorView 视图实例
    const editorView = new EditorView(elContentRef.current, {
      state,
    //处理编辑器中的事务(transaction),并在每次事务应用后更新编辑器的状态,并调用 onChangeHtml 回调函数通知外部编辑器内容的变化。
      dispatchTransaction(transaction) {
        const newState = editorView.state.apply(transaction);
        editorView.updateState(newState);
        onChangeHtml(editorView.dom.innerHTML);
      },
    });
    editorViewRef.current = editorView;
    return () => {
      editorView.destroy();
    };
  }, []);
  return (
    <div className="relative">
      <div ref={elContentRef} />
    </div>
}

此时我们需要把 Editor 组件挂载到 App.tsx 组件当中,一并显示原生 HTML:

//App.tsx
import { useState } from "react";
import Editor from "./components/Editor";

const INITIAL_HTML = `
<p style="text-align: center">He<strong>llo!</strong>__<span style="font-size: 20px;"><span style="color: orange">World</span></span></p>
<p><u><span style="font-size: 24px">你好啊</span></u><em><u>阿萨德</u></em></p>
<p><a href="https://google.com" target="_blank">Google</a></p>
<p style="text-align: right"><a href="https://github.com/ljq0226/my-prosemirror" target="_blank">GitHub</p>
`;
function App() {
  const [html, setHtml] = useState(INITIAL_HTML);
  return (
    <div className="h-screen w-screen p-[200px] bg-slate-300">
      <h1 className="text-center text-2xl">My-ProseMirror-Editor</h1>
      <div className="h-[800px] bg-white w-full py-10 px-8">
        <Editor
          initialHtml={html}
          onChangeHtml={(newHtml: string) => {
            setHtml(newHtml);
          }}
        />
        <div className="w-full h-1 bg-black" />
        <div>
          <h3>原生HTML</h3>
          <div>{html}</div>
        </div>
        <div className="w-full h-1 bg-black" />
        <div>
          <h3>渲染后</h3>
          <div
            dangerouslySetInnerHTML={{
              __html: html
            }}
          />
        </div>
      </div>
    </div>
  )
}

export default App

效果如下: 用 ProseMirror  实现一个富文本编辑器[Demo]

代码仓库:commit1

Add Menu

“菜”看起来已经做好了,但似乎我们跳过了“调味”这一步骤,本文以 ”切换 underline“和”设置字体大小“为例:

EditorMenu 菜单栏

创建 EditorMenu.tsx,并加入到 Editor.tsx 中,

//EditorMenu.tsx
import { EditorView } from "prosemirror-view";
import { toggleMark, setBlockType } from "prosemirror-commands";
import { schema } from "../../schema";
import { isActiveMark } from "./isActiveMark";
export type EditorMenuProps = {
  editorView: EditorView;
};
export const EditorMenu: FC<EditorMenuProps> = ({editorView}) => {
  return (
   <div className="border">
    <button
        className="w-6 h-6 bg-[#efefef] border border-black"
        style={{
          fontWeight: isActiveMark(
            editorView.state,
            schema.marks.underline
          )
            ? "bold"
            : undefined
        }}
        onClick={() => {
          toggleMark(schema.marks.underline)(
            editorView.state,
            editorView.dispatch,
            editorView
          );
          editorView.focus();
        }}
      >
        U
    </button>
    <MenuItemFontSize
        editorState={editorView.state}
        onSetFontSize={(fontSize) => {
          addMark(schema.marks.size, { fontSize })(
            editorView.state,
            editorView.dispatch,
            editorView
          );
          editorView.focus();
        }}
        onResetFontSize={() => {
          removeMark(schema.marks.size)(
            editorView.state,
            editorView.dispatch,
            editorView
          );
          editorView.focus();
        }}
    />
    </div>
 )
}

MenuItemFontSize:

import { FC, useMemo } from "react";
import { EditorState } from "prosemirror-state";
import { getSelectionMark } from "../getSelectionMark";
import { schema } from "../../../schema";

export type MenuItemFontSizeProps = {
  editorState: EditorState;
  onSetFontSize: (fontSize: string) => void;
  onResetFontSize: () => void;
};

const FONT_SIZE_LIST = ["12px", "16px", "24px"];

export const MenuItemFontSize: FC<MenuItemFontSizeProps> = ({ editorState, onResetFontSize, onSetFontSize }) => {
  const selectedFontSize = useMemo(() => {
    const mark = getSelectionMark(editorState, schema.marks.size);
    return mark ? mark.attrs.fontSize : "16px";
  }, [editorState]);
  return (
    <select
      value={selectedFontSize}
      onChange={(event) => {
        const fontSize = event.currentTarget.value;
        if (fontSize === "16px") {
          onResetFontSize();
        } else {
          onSetFontSize(fontSize);
        }
      }}
    >
      {FONT_SIZE_LIST.map((fontSize) => (
        <option key={fontSize} value={fontSize}>
          {fontSize}
        </option>
      ))}
    </select>
  );
};

//app.tsx
  return (
    <div className="relative">
      {editorViewRef.current && (
        <EditorMenu editorView={editorViewRef.current} />
      )}
      <div ref={elContentRef} />
    </div>
  );

执行操作介绍

toggleMark 和自定义的 addMark 函数分别处理不同类型的操作需求:

  • toggleMark(切换状态,如切换加粗、斜体等): toggleMark 是 ProseMirror 库提供的一个命令函数,用于在选定的文本上切换指定类型的标记。它会检查当前文本是否已经应用了该标记,如果已经应用则会将其移除,如果未应用则会添加该标记。toggleMark 函数的作用是在切换标记的状态,例如切换加粗、斜体等。
  • addMark(指定状态,如处理颜色、字体大小等) 自定义的 addMark 函数是根据业务需求自行定义的,用于向选定的文本添加指定类型的标记。它会将指定的标记应用于选定的文本范围,不会检查标记是否已经存在或进行切换。addMark 函数的作用是直接添加指定的标记,无论当前文本是否已经应用了该标记。

add为例: addMark 函数的作用是创建一个 ProseMirror 命令,用于向选定的文本添加指定类型的标记。该命令会根据选择的情况,在光标位置或选区范围内进行标记的添加,并在需要时更新编辑器状态。 用于创建一个 ProseMirror 命令(Command),用于向选定的文本添加标记(mark)

//addMark.ts
import { MarkType, Attrs } from "prosemirror-model";
import { Command, TextSelection } from "prosemirror-state";
import { markApplies } from "./markApplies";
/**
 * https://github.com/ProseMirror/prosemirror-commands/blob/1.3.1/src/commands.ts#L505-L538
 */
export const addMark = (
  markType: MarkType,
  attrs: Attrs | null = null
): Command => {
//接受两个参数:`state`(编辑器状态)和 `dispatch`(派发函数)。
  return (state, dispatch) => {
  //1. 首先,从编辑器状态的选择(`state.selection`)中获取相关信息,包括是否为空选区(`empty`)、光标位置(`$cursor`)和选区范围(`ranges`)。
    const { empty, $cursor, ranges } = state.selection as TextSelection;
    //2. 检查是否满足添加标记的条件,即如果是空选区且没有光标,或者标记不适用于给定的文档范围,则返回 `false`。
    if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType))
      return false;

    if (dispatch) {
    //如果存在光标位置(即非空选区),则使用 `state.tr.addStoredMark` 方法,在当前位置添加指定的标记(使用 `markType.create` 创建标记对象)。
      if ($cursor) {
        dispatch(state.tr.addStoredMark(markType.create(attrs)));
      } else {
      //否则遍历选区范围,并使用 `tr.addMark` 方法,在每个范围内添加指定的标记。在添加标记之前,还会根据节点内容的前后空白进行调整,以确保标记应用的正确性。
        let tr = state.tr;
        for (let i = 0; i < ranges.length; i++) {
          const { $from, $to } = ranges[i];
          let from = $from.pos,
            to = $to.pos;
          const start = $from.nodeAfter,
            end = $to.nodeBefore;
          const spaceStart =
            start && start.isText ? /^\s*/.exec(start.text!)![0].length : 0;
          const spaceEnd =
            end && end.isText ? /\s*$/.exec(end.text!)![0].length : 0;
          if (from + spaceStart < to) {
            from += spaceStart;
            to -= spaceEnd;
          }
          tr = tr.addMark(from, to, markType.create(attrs));
        }
        dispatch(tr.scrollIntoView());
      }
    }
    return true;
  };
};

这样我们就可以对文本进行 切换下划线和设置字体大小了,代码仓库:commit2 用 ProseMirror  实现一个富文本编辑器[Demo]

进一步完善commit3commit4

完整示例已部署在my-prosemirror-editor.netlify.app

最终效果: 用 ProseMirror  实现一个富文本编辑器[Demo]

参考文章:

转载自:https://juejin.cn/post/7264951394351628342
评论
请登录