ProseMirror Basic Example

prosemirror

ProseMirror Basic Example

ProseMirror 在官方文档中演示了如何制作一个基础编辑器,完整的源码可以查看相关的 Github 仓库

该示例使用了两个 ProseMirror 的非核心模块:

Prosemirror Example Setup 模块

prosemirror-example-setup 模块提供了示例代码,如何将 ProseMirror 各个模块「粘合」起来以构建一个编辑器

注意

它并不是 ProseMirror 的核心模块,其代码设计并不是为了可高度重用,只是为了快速构建一个基础编辑器以作演示

可以作为学习例子,但如果要用于生产环境中需要谨慎

该模块导出了一些辅助函数,便于生成一系列菜单项、应用输入规则 input rules、绑定快捷键

  • buildMenuItems(schema) 方法:基于给定的 schema(查看支持哪些类型的 nodes 和 marks)返回一个对象,以创建合适的菜单项
  • buildKeymap(schema, mapKeys?) 方法:基于给定的 schema(查看支持哪些类型的 nodes 和 marks),从预设的快捷键挑选出合适的绑定到编辑器上。
    第二个(可选)参数 mapKeys 是一个对象 {[key: string]: false | string} 用于调整快捷键。该模块预设了一些快捷键,通过该属性可以进行调整。属性的键名是所需调整的快捷键名称;键值如果是 false 表示不需要该快捷键,也可以是字符串表示将原有的快捷键更改为绑定其他键
  • buildInputRules(schema) 方法:返回一个插件实例,为编辑器应用一系列的输入规则,以创建基础的 blockquote 引文节点、lists 列表节点、code block 代码块节点、heading 标题节点

以上的辅助函数都整合到下面的 exampleSetup 方法中,所以使用该模块时可以只调用该方法

  • exampleSetup(potions) 方法:基于配置对象 options 创建一系列插件(以数组的形式,每个元素都是一个 Plugin 实例
    参数 options 是一个对象,可以包含以下属性
    • schema 属性:一个 Schema 数据约束对象
    • mapKeys(可选)属性:一个对象 {[key: string]: false | string} 用于调整快捷键
      该模块预设了一些快捷键,通过该属性可以进行调整。
      属性的键名是所需调整的快捷键名称;键值如果是 false 表示不需要该快捷键,也可以是字符串表示将原有的快捷键更改为绑定其他键
    • menuBar(可选)属性:一个布尔值,以设置是否为编辑器添加菜单栏
    • history(可选)属性:一个布尔值,以设置是否为编辑器添加 history 插件(支持撤销、重做功能)
    • floatingMenu(可选)属性:一个布尔值,以设置是否为编辑器应用浮动菜单
    • menuContent(可选)属性:一个数组,用于调整菜单项的内容
    插件分类

    按照功能分类所创建的插件类型如下

    • 应用一系列的输入规则,让富文本编辑器支持部分 Markdown 语法,即输入特定的字符以创建相应的节点(例如 > 加上空格键,创建一个 blockquote 引文节点)
    • 为编辑器绑定快捷键,以创建或操作节点
    • 将 prosemirror-commands 模块所提供的常见/通用快捷键绑定到编辑器上
    • 添加 undo 撤销功能
    • 添加 drop cursor 指针
    • 添加 gap cursor 指针
    • 为菜单栏添加菜单项
    • 添加 CSS 类名,以应用模块所提供的 style/style.css 样式文件

    以下是 exampleSetup 辅助函数的源码
    ts
    export function exampleSetup(options: {
      /// The schema to generate key bindings and menu items for.
      schema: Schema
    
      /// Can be used to [adjust](#example-setup.buildKeymap) the key bindings created.
      mapKeys?: {[key: string]: string | false}
    
      /// Set to false to disable the menu bar.
      menuBar?: boolean
    
      /// Set to false to disable the history plugin.
      history?: boolean
    
      /// Set to false to make the menu bar non-floating.
      floatingMenu?: boolean
    
      /// Can be used to override the menu content.
      menuContent?: MenuElement[][]
    }) {
      // 创建一系列插件
      let plugins = [
        // 使用辅助函数 buildInputRules 创建一个插件,将输入规则应用到编辑器(让富文本编辑器支持部分 Markdown 语法)
        buildInputRules(options.schema),
        // 使用方法 keymap() 创建一个插件,为编辑器绑定快捷键,这些快捷键主要关于创建或操作节点
        keymap(buildKeymap(options.schema, options.mapKeys)),
        // 使用方法 keymap() 创建一个插件,为编辑器绑定快捷键,这些快捷键主要关于通用的操作(由 prosemirror-commands 模块所提供)
        keymap(baseKeymap),
        // 使用方法 dropCursor() 创建一个插件,为编辑器添加 drop cursor 指针
        dropCursor(),
        // 使用方法 gapCursor() 创建一个插件,为编辑器添加 gap cursor 指针
        gapCursor()
      ]
    
      // 使用方法 menuBar() 创建一个插件,在编辑器的顶部添加菜单栏
      if (options.menuBar !== false)
        plugins.push(menuBar({floating: options.floatingMenu !== false,
                              content: options.menuContent || buildMenuItems(options.schema).fullMenu}))
    
      // 使用方法 history() 创建一个插件,为编辑器实现历史记录功能
      if (options.history !== false)
        plugins.push(history())
    
      // 最后创建一个插件为(编辑器所对应的 contenteditable)DOM 元素添加 class 类名 `"ProseMirror-example-setup-style"`
      // 返回一个数组,包含一系列 plugin 插件
      return plugins.concat(new Plugin({
        props: {
          attributes: {class: "ProseMirror-example-setup-style"}
        }
      }))
    }
    

下文对这些辅助函数进行解读

添加输入规则

构建插件添加输入规则,让富文本编辑器支持部分 Markdown 语法,输入规则的核心是正则表达式(用于匹配输入的内容,以触发相应的处理函数)

源文件

解读代码源自 prosemirror-example-setup 模块的 inputrules.ts 文件

其中主要使用以下两个函数,以快速构建输入规则实例

  • wrappingInputRule(regexp, nodeType, getAttrs?, joinPredicate?) 辅助函数:它会将光标所在的 textblock 文本块包裹在给定类型 nodeType 的节点中
  • textblockTypeInputRule(regexp, nodeType, getAttrs?) 辅助函数:它会将光标所在的 textblock 文本块转换为给定类型 nodeType 的节点
匹配的符号会被删掉

在 ProseMirror 预设 handler 处理函数的 input rules 中(例如使用辅助函数 wrappingInputRuletextblockTypeInputRule 所构建的输入规则实例),都会将匹配到的特殊符号删掉,因为它们一般仅起到 hint 提示/触发执行转换的作用

针对一些节点所设置的输入规则:

提示

由于输入规则 input rule 的目的是响应用户输入,所以正则表达式会应用于正处于光标前面的文本内容,相应地正则表达式一般以锚点 $ 结束,以约束匹配的内容是位于文本段落的最后(即由用户输入的)

  • blockquote 引文节点
    blockquote input rule
    blockquote input rule
    ts
    export function blockQuoteRule(nodeType: NodeType) {
      // 使用给定类型的节点包裹光标所在的文本块
      return wrappingInputRule(
        // 匹配规则:在文本块的(使用边界词 `^` 表示)开头位置是 `>` 大于号(它的前面可以有零个或多个空格,使用 `*` 量词表示)
        // 然后在后面要跟着有一个空格(并且使用边界词 `$` 表示该空格是在最后的)之后紧跟着就是光标
        /^\s*>\s$/,
        // 容器节点是引文节点
        nodeType
      )
    }
    
  • order list 有序列表节点
    order list
    order list
    ts
    export function orderedListRule(nodeType: NodeType) {
      // 使用给定类型的节点包裹光标所在的文本块
      return wrappingInputRule(
        // 匹配规则:在文本块的(使用边界词 `^` 表示)开头位置是数字(一个或多个,使用 `+` 量词表示),后面要跟着有一个点 `.`
        // 然后在后面要跟着有一个空格(并且使用边界词 `$` 表示该空格是在最后的)之后紧跟着就是光标
        /^(\d+)\.\s$/,
        // 容器节点是有序列表节点
        nodeType,
        // 设置列表节点的 attribute,返回一个对象 {order: number} 以设置有序列表的开始计数的数值
        // 这里采用正则表达式匹配结果(一个数组)的第二个元素,即第一个捕获组(转换为数字)作为有序列表的开始数值
        match => ({order: +match[1]}),
        // 第四个参数是一个函数,返回一个布尔值,以判断是否与前一个相邻节点合并
        // 该函数的第一个参数是正则表达式匹配结果(一个数组),第二个参数是前一个相邻节点
        // 当新生成的节点(容器节点)与前一个相邻的节点类型相同时,默认进行合并
        // 这里对同类型节点合并进行灵活度更高的定制操作
        // 前一个相邻节点除了是有序列表以外,还需要满足列表的序号可以「续上」
        // node.attrs.order 是前一个相邻有序列表的开始数值,node.childCount 是该有序列表拥有的子节点(列表项)数量
        // 则 node.childCount + node.attrs.order 就是新增列表项的序号,如果等于新生成的有序列表的开始数值,则两个有序列表可以合并
        (match, node) => node.childCount + node.attrs.order == +match[1]
      )
    }
    
  • bullet list 无序列表节点
    bullet list
    bullet list
    ts
    export function bulletListRule(nodeType: NodeType) {
      // 使用给定类型的节点包裹光标所在的文本块
      return wrappingInputRule(
        // 匹配规则:在文本块的(使用边界词 `^` 表示)开头位置可以是 `-`(连字符)、`+`(加号)、`*`(星号)这三种符号之一(它的前面可以有零个或多个空格,使用 `*` 量词表示)
        // 然后在后面要跟着有一个空格,(并且使用边界词 `$` 表示该空格是在最后的)之后紧跟着就是光标
        /^\s*([-+*])\s$/,
        // 容器节点是无序列表节点
        nodeType
      )
    }
    
  • code block 代码块节点
    code block
    code block
    ts
    export function codeBlockRule(nodeType: NodeType) {
      // 使用给定类型的节点替换光标所在的文本块
      return textblockTypeInputRule(
        // 匹配规则:在文本块的(使用边界词 `^` 表示)开头位置是 ```(三个反引号),之后紧跟着就是光标(使用边界词 `$` 表示是在最后)
        /^```$/,
        // 转变的类型是代码块节点
        nodeType
      )
    }
    
  • heading 标题节点
    ts
    // 参数 maxLevel 表示 # 井号的最多重复次数(一般是 6,即六级标题)
    export function headingRule(nodeType: NodeType, maxLevel: number) {
      // 使用给定类型的节点替换光标所在的文本块
      return textblockTypeInputRule(
        // 使用构造函数 new RegExp() 可以构建(基于参数)动态的正则表达式
        // 这里会使用参数 maxLevel(一个数字,表示标题的级别)设置所需匹配的模式种 `#` 井号所需重复的数量
        // 匹配规则,在文本块的(使用边界词 `^` 表示)开头位置是一些 #(井号,重复的数量范围是 1 到 maxLevel,使用量词 {1, maxLevel} 表示)
        // 然后在后面要跟着有一个空格,(并且使用边界词 `$` 表示该空格是在最后的)之后紧跟着就是光标
        // ⚠️ 使用 new RegExp() 创建正则表达式时,如果使用 \s(表示空格),需要对其添加额外的转义符 \ 具体说明参数 https://javascript.info/regexp-escaping#new-regexp
        new RegExp("^(#{1," + maxLevel + "})\\s$"),
        // 转变的类型是标题节点
        nodeType,
        // 设置标题节点的 attribute,返回一个对象 {level: number} 以设置标题节点的层级
        // 这里采用正则表达式匹配结果(一个数组)的第二个元素,即第一个捕获组,其内容(字符串,一堆 # 井号)的长度作为标题的级别
        match => ({level: match[1].length})
      )
    }
    
  • 特殊符号的转换
    ts
    // emDash 是将两个短斜杠转换为一个长破折号的输入规则实例
    // ellipsis 是将三个点转换为一个省略号输入规则实例
    // smartQuotes 变量由 prosemirror-inputrules 模块导出,它是一个数组,包含 4 种与引号相关的输入规则实例
    // 具体参考 https://github.com/ProseMirror/prosemirror-inputrules/blob/master/src/rules.ts#L17
    // 这里将 ellipsis 和 emDash 整合进 smartQuotes 数组中
    let rules = smartQuotes.concat(ellipsis, emDash)
    

最后使用方法 inputRules({rules}) 创建一个 plugin 插件(将插件注册到编辑器上,就可以应用特定的一系列输入规则)。其中参数 {rules} 表示它是一个对象,具有属性 rules 其值为一个数组 InputRule[]

ts
// 该辅助函数最后返回一个 plugin 插件
export function buildInputRules(schema: Schema) {
  // 将所创建的输入规则实例「整合」在一个数组 rules 里面
  let rules = smartQuotes.concat(ellipsis, emDash), type
  // 这里需要判断 schema 里是否有提供相应的节点类型,才在 rules 数组里添加相应的输入规则
  if (type = schema.nodes.blockquote) rules.push(blockQuoteRule(type))
  if (type = schema.nodes.ordered_list) rules.push(orderedListRule(type))
  if (type = schema.nodes.bullet_list) rules.push(bulletListRule(type))
  if (type = schema.nodes.code_block) rules.push(codeBlockRule(type))
  if (type = schema.nodes.heading) rules.push(headingRule(type, 6))
  // 最后使用方法 inputRules({rules}) 创建一个 plugin 插件
  return inputRules({rules})
}

绑定快捷键

源文件

解读代码源自 prosemirror-example-setup 模块的 keymap.ts 文件

构建一个对象 {[key: string]: Command} 以键值对的形式将按键和指令进行匹配

常见通用的快捷键

在 prosemirror-commands 模块也有提供了一个对象 baseKeymap,以键值对的形式将常见/通用的编辑器快捷键和指令进行匹配,具体可以参考源码

ts
export function buildKeymap(schema: Schema, mapKeys?: {[key: string]: false | string}) {
  // 初始化一个空的对象 keys,以键值对的形式将按键和指令进行匹配
  let keys: {[key: string]: Command} = {}, type

  // 函数 bind 将快捷键名称 key 和指令 cmd 相「绑定」
  function bind(key: string, cmd: Command) {
    // 会根据参数 mapKeys 判断是否需要对当前的快捷键进行调整
    if (mapKeys) {
      // 当 mapKeys 中包含该快捷键 key
      let mapped = mapKeys[key]
      // 如果该快捷键在 mapKeys 中映射值为 false,则表示取消快捷键,则直接返回,不执行后续的绑定操作
      if (mapped === false) return
      // 如果该快捷键在 mapKeys 中映射为其他快捷键,则调整快捷键名称
      if (mapped) key = mapped
    }
    // 为对象 keys 添加该快捷键与指令的映射关系
    keys[key] = cmd
  }

  // 💡 使用 Mod- 作为 MacOS 系统上 Cmd- 与其他系统上的 Ctrl- 的通用别名

  // 将 Mod-z 快捷键绑定 undo 指令(撤销修改)
  bind("Mod-z", undo)
  // 将 Shift-Mod-z 快捷键绑定 redo 指令(重做修改)
  bind("Shift-Mod-z", redo)
  // 将 Backspace 快捷键绑定 undoInputRule 指令(以撤销由输入规则所触发的转换操作,而且该操作是编辑器的最后一次执行的转换操作)
  bind("Backspace", undoInputRule)

  // 如果用户的系统不是 Mac,则将 Mod-y 快捷键绑定 redo 指令(重做修改)
  if (!mac) bind("Mod-y", redo)

  // 将 Alt-ArrowUp 快捷键绑定 joinUp 指令(将当前 block 块节点与前面相邻的兄弟节点相融合)
  bind("Alt-ArrowUp", joinUp)
  // 将 Alt-ArrowDown 快捷键绑定 joinDown 指令(将当前 block 块节点与后面相邻的兄弟节点相融合)
  bind("Alt-ArrowDown", joinDown)
  // 将 Mod-BracketLeft 快捷键绑定 lift 指令(提升 lift 当前选中节点的层级,将它移出父节点)
  // 💡 BracketLeft 是指键盘上的左方括号键 [
  bind("Mod-BracketLeft", lift)
  // 将 Escape 快捷键绑定 selectParentNode 指令
  bind("Escape", selectParentNode)

  if (type = schema.marks.strong) {
    // 如果 schema 支持 strong 加粗样式标记
    // 则将 Mod-b 和 Mod-B 快捷键绑定 toggleMark(schema.marks.strong) 指令(切换加粗样式)
    bind("Mod-b", toggleMark(type))
    bind("Mod-B", toggleMark(type))
  }

  if (type = schema.marks.em) {
    // 如果 schema 支持 em 斜体样式标记
    // 则将 Mod-i 和 Mod-I 快捷键绑定 toggleMark(schema.marks.em) 指令(切换斜体样式)
    bind("Mod-i", toggleMark(type))
    bind("Mod-I", toggleMark(type))
  }

  // 如果 schema 支持 code 代码样式标记
  // 则将 Mod-` 快捷键绑定 toggleMark(schema.marks.code) 指令(切换代码样式)
  if (type = schema.marks.code)
    bind("Mod-`", toggleMark(type))

  // 如果 schema 支持 bullet_list 无序列表节点
  // 则将 Shift-Ctrl-8 快捷键绑定 wrapInList(schema.nodes.bullet_list) 指令(将用户选中的内容所在的块节点包裹进无序列表节点里)
  if (type = schema.nodes.bullet_list)
    bind("Shift-Ctrl-8", wrapInList(type))

  // 如果 schema 支持 ordered_list 有序列表节点
  // 则将 Shift-Ctrl-9 快捷键绑定 wrapInList(schema.nodes.ordered_list) 指令(将用户选中的内容所在的块节点包裹进有序列表节点里)
  if (type = schema.nodes.ordered_list)
    bind("Shift-Ctrl-9", wrapInList(type))

  // 如果 schema 支持 blockquote 引文节点
  // 则将 Ctrl-> 快捷键绑定 wrapIn(schema.nodes.blockquote) 指令(将用户选中的内容所在的块节点包裹进引文节点里)
  // ⚠️ 在 Windows 系统中该快捷键是 Shift-Ctrl->
  if (type = schema.nodes.blockquote)
    bind("Ctrl->", wrapIn(type))

  if (type = schema.nodes.hard_break) {
    // 如果 schema 支持 hard_break 换行节点
    let br = type;
    // 使用方法 chainCommands 将给定的一系列指令封装为一个新的指令
    // 依次执行 exitCode 指令(由于在代码类型的节点中,不能插入换行节点,需要先跳出该节点),然后将选区内容替换为新建的换行节点,最后将光标滚动到视图中
    let cmd = chainCommands(exitCode, (state, dispatch) => {
      if (dispatch) dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView())
      return true
    })

    // 则将 Mod-Enter 和 Shift-Enter(如果用户的系统是 Mac 还将 Ctrl-Enter)快捷键绑定前面所构建的 cmd 指令(插入 <br/> 进行硬换行)
    bind("Mod-Enter", cmd)
    bind("Shift-Enter", cmd)
    if (mac) bind("Ctrl-Enter", cmd)
  }

  if (type = schema.nodes.list_item) {
    // 如果 schema 支持 list_item 列表项节点
    // 则将 Enter 快捷键绑定 splitListItem(schema.nodes.list_item) 指令(分割列表项的直接子节点的内容)
    bind("Enter", splitListItem(type))
    // 则将 Mod-[ 快捷键绑定 liftListItem(schema.nodes.list_item) 指令(将选中的列表项的内容往上提升一级,跳出当前列表)
    bind("Mod-[", liftListItem(type))
    // 则将 Mod-] 快捷键绑定 sinkListItem(schema.nodes.list_item) 指令(将选中的列表项内容往内缩进一级,到一个嵌套列表中去)
    bind("Mod-]", sinkListItem(type))
  }

  // 如果 schema 支持 paragraph 段落节点
  // 则将 Shift-Ctrl-0 快捷键绑定 setBlockType(schema.nodes.paragraph) 指令(将选区所在的 textblock 文本块转换为段落节点)
  // ⚠️ 在 Windows 系统中似乎无效
  if (type = schema.nodes.paragraph)
    bind("Shift-Ctrl-0", setBlockType(type))

  // 如果 schema 支持 code_block 代码块节点
  // 则将 Shift-Ctrl-\\ 快捷键绑定 setBlockType(schema.nodes.code_block) 指令(将选区所在的 textblock 文本块转换为代码块节点)
  // 💡 \\ 第一个 \ 符号是转义符 ❓ 第二个 \ 符号表示反斜杠
  if (type = schema.nodes.code_block)
    bind("Shift-Ctrl-\\", setBlockType(type))

  // 如果 schema 支持 heading 标题节点
  // 则依次将 Shift-Ctrl-i(其中 i 表示与标题级别相应的数字)快捷键绑定 setBlockType(type, {level: i}) 指令(将选区所在的 textblock 文本块转换为相应级别的标题节点)
  if (type = schema.nodes.heading)
    for (let i = 1; i <= 6; i++) bind("Shift-Ctrl-" + i, setBlockType(type, {level: i}))

  // 如果 schema 支持 horizontal_rule 水平线节点
  if (type = schema.nodes.horizontal_rule) {
    let hr = type

    // 则将 Mod-_ 快捷键绑定到所构造的指令(将选区内容替代为新建的水平线节点,然后将光标滚动到视图中)
    bind("Mod-_", (state, dispatch) => {
      if (dispatch) dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView())
      return true
    })
  }

  // 最后返回对象 keys,它以键值对的形式将按键和指令进行匹配
  return keys
}
创建插件

方法 buildKeymap() 只是构建一个对象,不能直接使用。还需要使用方法 keymap(bindings) 构建一个 plugin 插件,将插件注册到编辑器上,就可以实现按键与指令的绑定

其中参数 bindings 是一个对象,以键值对的形式将按键和指令进行匹配。在 exampleSetup() 辅助函数里就是前面介绍的方法 buildKeymap() 构建出一个对象

菜单

源文件

解读代码源自 prosemirror-example-setup 模块的 menu.ts 文件

主要是基于 prosemirror-menu 模块来构建菜单栏

prosemirror-menu 模块用于构建编辑器的菜单栏,但它并不是核心模块,仅为了快速构建编辑器示例(以进行演示)

可以作为学习例子(如果要用于实际生产环境中则可能需要进行更多深度定制)

  • 基于 editorView 编辑器视图(其中的 editorState 是编辑器状态)实现菜单项的动态渲染
    菜单项的多种状态

    菜单项除了可以响应用户点击(以执行相应的操作),还可以根据不同的场景设置不同的状态(对应于不同的 CSS 样式):

    • 显示/隐藏:例如当光标在 code block 代码块里,则菜单栏与样式标记相关的菜单项应该隐藏掉(也可以设置为 disable 失活状态),不允许用户对代码块的文本设置加粗、斜体等样式
    • 激活:例如光标所在的文本已经被加粗,则加粗按钮应该高亮显示,以表示该样式标记已激活
    • 失活:例如当选区为空时,添加链接的按钮应该以浅色显示,表示该按钮当前不可用

    具体实现可参考 class MenuItem

    实例化 MenuItem 类时,该类的实例化对象的方法 render 负责在页面渲染出菜单项,以及更新菜单项的状态

    render 接收 editorView 作为参数,这样菜单项就可以获取编辑器的状态,最后返回一个对象 {dom, update},其中属性 dom(菜单项所对应的 DOM 元素),属性 update 方法(更新菜单项的状态)

    其中方法 update 根据编辑器当前 editorState 编辑器状态动态更新菜单项:

    • 其中方法 spec.select(state) 返回一个布尔值,以控制该菜单项的显示或隐藏(通过设置 CSS 的 display 属性)
    • 其中方法 spec.enable(state) 返回一个布尔值,以表示该菜单项是否失活(通过为 DOM 设置 class 类名 disabled
    • 其中方法 spec.active(state) 返回一个布尔值,以表示该菜单项是否激活(需要同时满足 enable 为真,通过为 DOM 设置 class 类名 active,)
  • 要实现点击菜单项执行特定操作的功能,可以监听 DOM 鼠标事件(clickmousedown),在事件处理函数中执行相关操作
    具体可参考 class MenuItem
  • 如果要判断一个 command 指令是否可用,可以使用 command(state) 只传递第一个参数,根据返回的布尔值来判断当前状态下该指令是否可用
    指令 command 是一个函数 fn(state, dispatch?, view?) 它可以接受三个参数,当没有设置第二个参数 dispatch 时,表示该命令只是一个 dry run 「试运行」,即进行模拟或预演操作(以判断它是否应该被执行),但不对文档进行实际修改
  • 判断用户的系统是否为 iOS
    ts
    function isIOS() {
      if (typeof navigator == "undefined") return false
      let agent = navigator.userAgent
      return !/Edge\/\d/.test(agent) && /AppleWebKit/.test(agent) && /Mobile\/\w+/.test(agent)
    }
    
  • 创建插件将菜单栏插入到编辑器里

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes