如何用 ProseMirror 解析和渲染 markdown
前言上一篇我们简单搭建了一个 electron 应用的开发环境,启动后,窗口加载了一个网页。现在我们要加入我们的主体功能 —— Markdown。在这里,我借助 ProseMirror 来开发编辑器。我们先简单学习一下如何用 ProseMirror 搭建一个富文本编辑器。
ProseMirror简易编辑器ProseMirror 有几个必要的模块:
prosemirror-model定义了编辑器的Document Model, 它用来描述编辑器的内容.prosemirror-state提供了一个描述编辑器完整状态的单一数据结构, 包括编辑器的选区操作, 和一个用来处理从当前 state 到下一个 state 的一个叫做transaction的系统.prosemirror-view用来将给定的 state 展示成相对应的可编辑元素显示在编辑器中, 同时处理用户交互.prosemirror-transform包含了一种可以被重做和撤销的修改文档的功能, 它是prosemirror-state库transaction功能的基础, 这使得撤销操作历史记录和协同编辑成为可能.其他还有一些模块,如prosemirror-collab、prosemirror-gapcursor等,使用频率也很高,具体看需求。
现在我们简单地使用上面几个模块来构建一个编辑器。
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Schema, DOMParser } from "prosemirror-model";
import { schema } from "prosemirror-schema-basic";
import { useEffect, useRef } from "react";
import React from "react";
export const Editor = () = {
const schemaIntance = new Schema({
nodes: schema.spec.nodes,
marks: schema.spec.marks,
});
const view = useRefEditorView
useEffect(() = {
view.current = new EditorView(document.getElementById("editor"), {
state: EditorState.create({
doc: DOMParser.fromSchema(schemaIntance).parse(
document.getElementById("content")!
),
schema: schemaIntance
}),
});
}, []);
return (
div
div id="content" style={{ height: 0, overflow: "hidden" }}
Hello ProseMirror
/div
div id="editor"/div
/div
);
};
image.png这里的schema我就直接用的prosemirror-schema-basic提供,支持的Node和Mark不多。
上面这个编辑器还很简陋,像输入回车键换行等常见的操作都还没有。我们可以借助 ProseMirror 的其他模块继续增强编辑器的能力。官方已经提供了一些常用的模块:
prosemirror-history该模块实现了 redo/undo 操作。
prosemirror-keymap该模块可以将键名映射到 command 类型的函数。
prosemirror-commands这个包提供了很多基本的编辑 commands, 包括在编辑器中按照你的期望映射 enter 和 delete 按键的行为。
import{undo,redo,history}from"prosemirror-history";
import{keymap}from"prosemirror-keymap";
import{baseKeymap}from"prosemirror-commands";
//...
useEffect(()={
view.current=newEditorView(document.getElementById("editor"),{
state:EditorState.create({
doc:DOMParser.fromSchema(schemaIntance).parse(
document.getElementById("content")!
),
schema:schemaIntance,
plugins:[
history(),
keymap({"Mod-z":undo,"Mod-y":redo}),
keymap(baseKeymap),
],
}),
},
如图所示,我加入了keymap和history,现在这个编辑器支持redoundo了。
Pluginsprosemirror-stateprosemirror-state提供了编写插件的能力,上面用的keymap和history都是Plugin。
import{Plugin}from"prosemirror-state"
functionkeydownHandler(bindings){
returnfunction(view,event){
//...
}
}
constkeymap=function(bindings){
returnnewPlugin({
props:{
handleKeyDown:keydownHandler(bindings)
}
})
}
keymap插件去除其他复杂的逻辑,核心的部分就是上图所示。
prosemirror-inputrulesprosemirror-inputrules这个模块可以配置匹配规则,当我们输入的文本符合条件时,可以替换成别的文本或者节点, 或者用transaction做更复杂的事情。它内置一些inputrule,比如emDash, 连续输入两个-会替换成一个——,
我们想要实现的Markdown是像Typora的那种,这里需要借助prosemirror-inputrules。我们以输入 **hello** 自动变成hello为例
import{inputRules,InputRule}from"prosemirror-inputrules";
//...
view.current=newEditorView(document.getElementById("editor"),{
state:EditorState.create({
doc:DOMParser.fromSchema(schemaIntance).parse(
document.getElementById("content")!
),
schema:schemaIntance,
plugins:[
inputRules({
rules:[
newInputRule(
/\*\*[^\*]{1,}\*\*$/,
(state:EditorState,match,from,to,text)={
constmark=state.schema.mark("strong");
conststr=match[0].substring(2,match[0].length-2);
constnode=state.schema.text(str,[mark]);
returnstate.tr.replaceWith(from,to,node);
}
),
],
}),
],
}),
md-strong.gifprosemirror-inputrules还提供了一个 command ——undoInputRule, 它可以将我们刚刚输入的hello重新变成 **hello**,我们结合history和keymap来使用。
import{undo,redo,history}from"prosemirror-history";
import{keymap}from"prosemirror-keymap";
import{baseKeymap,chainCommands}from"prosemirror-commands";
import{
inputRules,
InputRule,
undoInputRule,
}from"prosemirror-inputrules";
//...
view.current=newEditorView(document.getElementById("editor"),{
state:EditorState.create({
doc:DOMParser.fromSchema(schemaIntance).parse(
document.getElementById("content")!
),
schema:schemaIntance,
plugins:[
history(),
keymap({
"Mod-z":chainCommands(undoInputRule,undo),
"Mod-y":redo,
}),
keymap(baseKeymap),
inputRules({
rules:[
newInputRule(
/\*\*[^\*]{1,}\*\*$/,
(state:EditorState,match,from,to,text)={
constmark=state.schema.mark("strong");
conststr=match[0].substring(2,match[0].length-2);
constnode=state.schema.text(str,[mark]);
returnstate.tr.replaceWith(from,to,node);
}
),
],
}),
],
}),
undo-inputrule.gif可以看到,按键CommandOrCtrl+z可以回到之前的状态,再重新修改。
Remirror前面,我们都是用ProseMirror来构建编辑器的。现在,我要给大家介绍一个很好用的 react 库 ——Remirror。
Remirror是基于ProseMirror的用于构建跨平台富文本编辑器的 React 工具包,它可以和 react 很好的配合工作。Remirror 同样不是像 Draft.js 那样是一个开箱即用的方案。
Installnpmaddremirror@remirror/react@remirror/pm
Create Manager开始初始化一个 remirror 编辑器之前,我们要先创建一个 manager,它用来控制编辑器的行为。 manager 只提供了最基础基础的功能,需要配合 extensions 才能发挥作用。这里我使用了 MarkdownExtension 扩展,它给我们提供了简单的解析 markdown 的功能。
import React from "react";
import { MarkdownExtension, BoldExtension } from "remirror/extensions";
import { Remirror, useRemirror } from "@remirror/react";
import "remirror/styles/all.css";
const Editor = () = {
const { manager, state } = useRemirror({
extensions: () = [new MarkdownExtension()],
content: "",
selection: "start",
stringHandler: "markdown"
});
return (
div className="remirror-container"
Remirror manager={manager} initialContent={state} /
/div
);
};
export default function App() {
return Editor /
}
content 用于提供初始化的内容,你可以试试 content: "a"。codesandbox
如图所示,remirror/extensions 这个模块中还提供了很多内置的扩展,大家可以根据需要直接使用。
image.png添加扩展我们先添加一个 BoldExtension 看效果。codesandbox
import React from "react";
import { MarkdownExtension, BoldExtension } from "remirror/extensions";
import { Remirror, useRemirror } from "@remirror/react";
import "remirror/styles/all.css";
const Editor = () = {
const { manager, state } = useRemirror({
extensions: () = [new MarkdownExtension(), new BoldExtension()],
content: "",
selection: "start",
stringHandler: "markdown"
});
return (
div className="remirror-container"
Remirror manager={manager} initialContent={state} /
/div
);
};
export default function App() {
return Editor /
}
尝试输入 **bold**,可以看到编辑器自动将它转成了bold
自定义扩展@remirror 的扩展写起来是很方便的,我们简单来看一下 @remirror/extension-bold 的具体实现。 remirror 的扩展用到了很多 @remirror/core 提供的装饰器。
exportinterfaceBoldOptions{
weight?:StaticFontWeightProperty
}
@extensionBoldOptions({
defaultOptions:{weight:undefined},
staticKeys:['weight'],
})
exportclassBoldExtensionextendsMarkExtensionBoldOptions{
getname(){
return'bold'asconst;
}
createTags(){
return[ExtensionTag.FormattingMark,ExtensionTag.FontStyle];
}
createMarkSpec(extra:ApplySchemaAttributes,override:MarkSpecOverride):MarkExtensionSpec{
return{
...override,
attrs:extra.defaults(),
parseDOM:[
{
tag:'strong',
getAttrs:extra.parse,
},
//ThisworksaroundaGoogleDocsmisbehaviorwhere
//pastedcontentwillbeinexplicablywrappedin``
//tagswithafont-weightnormal.
{
tag:'b',
getAttrs:(node)=
isElementDomNode(node)node.style.fontWeight!=='normal'
?extra.parse(node)
:false,
},
{
style:'font-weight',
getAttrs:(node)=
isString(node)/^(bold(er)?|[5-9]\d{2,})$/.test(node)?null:false,
},
...(override.parseDOM??[]),
],
toDOM:(node)={
const{weight}=this.options;
if(weight){
return['strong',{'font-weight':weight.toString()},0];
}
return['strong',extra.dom(node),0];
},
}
createInputRules():InputRule[]{
return[
markInputRule({
regexp:/(?:\*\*|__)([^*_]+)(?:\*\*|__)$/,
type:this.type,
ignoreWhitespace:true,
}),
}
@command(toggleBoldOptions)
toggleBold(selection?:PrimitiveSelection):CommandFunction{
returntoggleMark({type:this.type,selection
}
@command()
setBold(selection?:PrimitiveSelection):CommandFunction{
return({tr,dispatch})={
const{from,to}=getTextSelection(selection??tr.selection,tr.doc);
dispatch?.(tr.addMark(from,to,this.type.create()));
returntrue;
}
@command()
removeBold(selection?:PrimitiveSelection):CommandFunction{
return({tr,dispatch})={
const{from,to}=getTextSelection(selection??tr.selection,tr.doc);
if(!tr.doc.rangeHasMark(from,to,this.type)){
returnfalse;
}
dispatch?.(tr.removeMark(from,to,this.type));
returntrue;
}
@keyBinding({shortcut:NamedShortcut.Bold,command:'toggleBold'})
shortcut(props:KeyBindingProps):boolean{
returnthis.toggleBold()(props);
}
}
这个里面的一些写法,看起来是不是和前面我们直接用ProseMirror实现的有些类似,实际就是用的那些模块。
渲染编辑器和菜单import{
BoldExtension,
HeadingExtension,
ItalicExtension,
MarkdownExtension,
PlaceholderExtension,
}from"remirror/extensions";
//...
constMenu=()=buttononClick={()=alert("buttonb")}/button
exportconstMarkdown=()={
const{manager,state}=useRemirror({
extensions:()=[
newPlaceholderExtension({
placeholder:"请输入文字",
}),
newBoldExtension(),
newItalicExtension(),
newMarkdownExtension(),
newHeadingExtension(),
],
content:"",
selection:"start",
stringHandler:"markdown",
return(
divclassName="remirror-container"
Remirrormanager={manager}initialContent={state}hooks={hooks}
Menu/
EditorComponent/
/Remirror
/div
};
当你需要自定义渲染编辑器的菜单时,你就需要使用EditorComponent,Remirror组件不接受 children 时默认会使用该组件渲染。这里Menu菜单就简单写了个按钮,后续我们再优化。
image.png总结基本上,参考Remirror的插件写法或者直接使用Remirror就可以简单构建出一个Markdown编辑器,接下来就看大家的发挥了。
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线