作者: fmw

  • NAS折腾记

    为什么组NAS?NAS硬件选购

    为什么组NAS?

    其实很早就知道NAS这玩意儿了,但是一直没有想要自己搭一个的想法,一来是因为贵,成品的NAS配置稍微正常点,基本都得要个三四千,属实是有点贵了,二来是觉得NAS这种东西还是得有家才行,毕竟一般NAS都是用来作为家庭存储中心,如果光有一个NAS而没有一些其他配套的设施一起使用,比如电视机等,感觉多少还是差点意思。直到有一天发现自己好几个PT站因为长期没有登录被封号,加上考虑到观影的需求,确实有个NAS会方便很多,终于在今年年初决定组一台NAS。

    为什么选择DIY一台NAS

    一直以来都想选择成品NAS,因为成品NAS都是经过设计的,不需要自己去选择各种硬件,体积也不会很大,也都有配套的软件系统,基本上不需要怎么折腾,拿到手稍微配置下就能用了。但是如果是自己DIY的话,你就得像组装电脑一样,自己去攒机。但是说实话,可能因为我不怎么打大型游戏,对高性能的台式机没有需求,所以平时也就没有自己去组装过电脑。对于硬件这块只能说了解个大概,让我自己去从头组装一台NAS我是没有啥信心的,因为有太多前置知识要学习。但是有一个特性改变了我的想法,就是虚拟化平台,之前在公司的服务器上接触到过vmware的虚拟化平台,虚拟化平台可以在一台性能强劲的电脑/服务器上安装多个虚拟化的系统,其实云服务器厂商就是这么做的,一般在云服务器厂商上面开通的实例其实本质就是一个虚拟机,例如阿里云的ECS,腾讯云的CVM等等。成品NAS的CPU性能都比较一般,性能合适的又非常贵,可能得上万了,但是如果是自己DIY,就可以选用一些服务器的CPU,服务器的CPU核心数非常多,完全可以虚拟出好多个系统,所以最终还是决定自己DIY一台NAS。另外成品NAS最知名的两个品牌就是群辉和威联通,群辉硬件配置低,软件好,威联通硬件配置高,软件差,所以都算不上是最好的选择。

    硬件选购

    既然决定要DIY,那就要考虑选配硬件了,所以就开始在网上查询一些NAS的配置,直到看到知乎的一篇文章[1],提问者的需求和我基本差不多,看到下面的回答有推荐一个HP的Z440准系统,于是便去了解了一下,发现很不错,是一款工作站,支持服务器系列的CPU,而且准系统就是包括主板电源机箱等除了CPU、显卡、硬盘的大部分配件,并且是已经组装好的,非常适合我这种没有攒机能力的人。有了准系统我只需要自行选购CPU硬盘等一些基础的配件就行。

    CPU是朋友送的E5-2680v4,显卡是Nvidia Tesla P4,外加一个亮机卡GT720,需要注意的是,这台机子是一个工作站,所以必须要有可以接显示输出的显卡,否则无法正常开机,硬盘是朋友送的希捷的16T机械+西数480G固态,还有四张8G的ECC内存(也是朋友送的😄)。

    至此,我的NAS就DIY完成。

    参考链接

    家里想搞个服务器,有什么好的建议方案吗? – 酒鬼怪叔叔的回答 – 知乎 https://www.zhihu.com/question/634626885/answer/3325320711

    自动化家庭影音中心搭建

    系统选择

    支持虚拟化的系统有UNRAID、PVE、ESXi,其实一开始想选择ESXi的,毕竟是VMware的,大厂稳定性有保证,但是后面了解了UNRAID之后,发现UNRAID内置Docker,相当于Docker是直接运行在系统里的,而不是运行在虚拟机里,这点可能在性能上会有优势一点,而且我的大部分需求基本都只需要Docker就行,于是果断选择了UNRAID。

    系统搭建

    UNRAID的搭建过程就不叙述了,网上有很多教程,重点讲下自动化影音中心的搭建。

    既然是影音中心,那么关键就是影音资源,影音资源可以来自自己的BT下载,PT(Private Torrent)资源站下载或者网盘资源等等。其中最好的应该属PT资源站,BT资源需要找,而且资源质量参差不齐,包含各种低俗、菠菜推广广告;网盘资源也存在和BT资源类似的问题,并且不是会员的话现在还会限速,大概率也不能使用网盘直接播放,虽然现在阿里云盘的口碑相对百度要好很多,但是网盘资源其实也是从各大PY资源站出来的,还得受制于发布人,有时候并不能及时更新;PT资源站资源更新及时,并且质量非常高(站内有管理组,会对上传的资源质量做出要求,不符合的会被删除),可以说是影音资源的最佳来源,但是天下没有免费的午餐,既然要享受这些好处,自然也是要付出一点什么,首先PT站的账号就不是那么好注册的,一般需要站内用户邀请,有些小站偶尔会开放注册,其次PT站有分享率(上传量/下载量)的要求,也就是你不能做伸手党,你下载多少流量的资源,就得通过做种上传多少流量的资源,如果分享率低于1,也就是下载的比上传的多,那么很有可能会被封号。

    我的PT站账号基本都是一个玩PT的朋友邀请的,早些时候有七八个站的,但是有好几个站因为我长期没有登录都被封了…不过还好,基本都是一些小站。下面这套自动化方案其实也是我这个朋友推荐的,因为之前在vps上也搭建过这一套,所以可以说是轻车熟路了,这套方案主要包含了以下几个软件

    • qBittorent-下载器
    • Plex-影音播放
    • Movie-Robot-自动化资源管理软件

    qBittorrent

    主要用于下载PT资源,在UNRAID里的应用商店直接搜qbittorrent的docker模板就行,我选择的是linuxserver的仓库,这个的配置很简单,也没啥好说的,主要是后面要做外网访问,如果做了端口转发,得到设置的「Web UI」选项卡里面把「启用 Host header 属性验证」关闭,否则转发后无法访问Web-UI

    贴一下我的容器配置

  • 用IOS的捷径打造公交到站查询神器

    需求引入

    相信很多上班族每天都要坐公交,我也是其中之一。公交不如地铁等交通工具,需要等待的时间往往不那么固定,有长有短。对于上班这个场景来说,我们坐公交的时间往往是固定的,所以如果能知道公交还有多久到站,等快到站了再去公交站,那么等待的时间就会大幅缩短,这样大夏天的时候也不至于去公交站晒太阳。

    为了解决这个问题,自然会想到在手机上通过一些实时公交查询平台查询还有多久到站。是的,相信大多数一二线城市都会有相应的查询平台,也可通过公交站是否有到站时间提示来判断,一般公交站有到站提示,那么基本上也就可以在手机上查询到。以我所在的城市上海为例,就有一个上海公交的公众号,可以通过公众号菜单的链接进入网页查询,输入线路名,选择方向,再选择站点,即可知道最近一辆还有多久到。这样每天早上又可以在家多呆一会了,懒癌患者就是我😎

    这样真的方便了许多,但是用了一段时间后,发现每天查询的过程实在是太繁琐了,每天都要先打开微信,搜索公众号,点击菜单打开网页,输入线路,选择方向,选择站点。如果要再快一点,那最多也就只能将网页收藏,省去了查询公众号。那这时就有人会说既然可以收藏网页,那你把查询结果那个页面收藏不就可以了。是的,没错,我也想到了,可惜的是,那个网页是webapp类型的,不是传统类型,所以收藏查询结果是无效的。那么还有什么办法可以更快的让我们查询到公交的到站时间呢?这就是本文接下来要解决的问题。

    原型设计

    既然查询平台是一个网页,那也就意味着我们可以通过抓包的方式,将网页的功能抽离出来,自己用另外的方式实现。比如自己可以制作一个一打开就出现查询结果的网页等等。但是本文不会采用这个方法,因为这个需求本身并没有很复杂,如果自己做个网页的话,还需要部署到一台服务器上,未免有些大材小用。iOS系统的捷径app正好可以满足这个需求,而且成本也非常低,只需一个捷径app就行,完全没有其他任何额外成本,使用起来也很方便,可以说是不二之选。

    根据前文提出的问题,我们需要解决的就是要将这些繁琐的步骤,尽可能的减少,最完美的就是能将它精简到一步。网页中的操作主要分为三步:

    1. 输入线路进行搜索,然后选择线路
    2. 选择线路方向
    3. 选择站点

    对网页的抓包结果的分析:

    1. 选择完线路点击搜索之后,网页会提交一个请求,这个请求会将我们选择的线路名称提交,然后服务端会给我们这次查询的线路生成一个随机的id,这个id绑定了我们查询的线路
    2. 网页会跳转到线路页面,线路页面上方可选择线路方向,下方为线路站点列表,点击任意一个站点,即会发起一个请求,分析请求,携带了三个参数,一个是上一步得到的id,一个是表示线路方向,还有一个就是站点的序号。根据对请求结果参数名的猜测,可以得到站点的距离(米),距当前站点的站点数(个),到达当前站点剩余时间(秒),车辆的车牌号

    结合上面的分析,发现通过抓包得到的信息比这个网页本身所提供的信息有用得多,网页上只显示了站点距离(个)、到站剩余时间(分)、车牌号,唯独没有显示距离(米),个人认为距离有时候比时间有价值的多,因为时间很容易受堵车等其他因素的影响,但是距离就不会。但是反过来想,在网页上显示距离(米)的成本很低,为什么确没有显示?我能想到的唯一原因就是这个距离是直线距离,不是实际行驶距离,所以没有很强的可参考性,但是再仔细一想,现在的路径算法非常成熟,应该不至于计算不出这种固定线路的距离。扯远了,回到正题,我们要做的很简单,就是模拟上面的两个请求就可以了。

    在对这个网页研究的时候发现了网页存在的一些问题:

    1. 网页的选择方向区域,点击之后居然不是根据点击的线路来执行,而是每次点击之后切换到另一个方向,虽然从用户的行为上来说,点击一般都是为了切换另一个方向,毕竟只有两个方向,如果不用切换也不会点击,但是从界面上来说,这个效果和界面设计应该是不一致的,至少我从界面上来看,会觉得是点哪个方向就切换到哪个方向,哪怕点击的就是当前显示的方向,如果是要像现在这种效果,那么应该由一个“切换方向”的按钮去承载。
    2. 不知道是开发者偷懒还是什么其他原因,第二个请求中的站点id参数,果真就是和界面上显示的一样,还带了一个点,如果是第一个站点,那么参数值就是“1.”,而不是1,有软件开发经验的都知道,id不会莫名其妙带上这么一个“.”,服务端必然会把“.”通过一些方法给去除,但是这就导致了维护上的一个问题,如果有新人接手了这个系统,那么新人必然会对这段去除“.”功能感到疑惑,如果前端在提交这个参数的时候,就去除了这个点,那自然是最好的,成本反而更低。当然,对于这样的系统来说,成本的多少可能并没有那么重要。

    开发实战

    说实话,我在制作这个查询公交的捷径之前,对捷径这个app的了解也仅限于知道它是一个工作流app,可以自己定义一系列的工作流,完成一些繁琐重复的事情,但是对于如何去自定义自己的工作流还是一脸懵逼。这次为了方便自己,所以花了点时间摸索了一下,观察学习了一些别人制作的捷径,才基本了解它的开发模式。捷径的前身是workflow,后来被苹果收购之后有个官方译名“捷径”,我还是觉得译成工作流更能反映它的能力。它是由一系列操作构成的,操作有输入输出,通过将一系列的操作串起来,就可以形成一个完整的工作流。不过编写这个的思维方式和实际计算机开发中编码的思维方式还是有点不一样,在这里操作的输入输出和上下文中紧跟的操作有强关联,换句话说就是操作的输出和紧跟后面的操作的输入是相连的,而不像计算机编码,前后两句并没有很强的关联性。

    为了解决我们的问题,我们需要用到的核心操作就是“获取 URL 内容”,这个操作可以帮我们发起一个请求,而我们需要做的只是发起两个请求。

    以108路金陵中路黄陂南路到高境路恒高路方向的人民广场站为例

    第一个请求中有个参数是线路的名称,叫idnum,我们只需要将自己乘坐的线路名称填入即可,由于请求返回的内容是一个JSON对象,我们可以利用捷径中的词典,解析这个JSON对象,并从中读取出sid参数,存储到变量中,供下一个请求使用。(后来分析返回的sid发现,这个sid其实就是线路的md5信息摘要,不会发生变化,所以这一个请求可有可无,但是出于保险起见,可以保留,避免服务端改变加密方式,从而导致下一个请求失败)

    第二个请求有三个参数,一个是上一个请求返回的sid,一个是表示方向的stoptype,还有一个就是站点序号stopid。返回的内容还是JSON,只不过不是对象,而是一个数组,这就有点尴尬,找了一圈也找到该怎么直接取数组的值,只知道可以取对象里面的数组值,但是直接读取数组的值就不太清楚了,所以我采取了一个构造法,通过文本拼接,构造出一个对象,将返回的数组放在这个对象中,然后再进行取值,成功!

    最后在通过“显示提醒”操作,将词典中的内容获取,拼接出一个提示信息即可,达到了文章开头视频中的效果

    验收交付

    经过对网页的分析以及工作流的制作,我们解决了文章开头提出的问题,完美实现了一键查询公交站点到站信息的功能,并且对捷径的使用有了初步的认识,希望大家可以自己尝试摸索捷径,通过制作工作流解决一些自己生活中的繁琐重复的事情,提高自己的效率。文末的阅读原文链接中,我也放上了自己制作的公交查询捷径,打开之后可以添加到自己的捷径中,有在上海的小伙伴可以通过自行修改里面的line(线路)、stopid(站点序号)、direction(方向)制作符合自己需求的捷径。文中所制作的捷径阅读原文中分享的,都还不是完美的,还存在一个问题,就是在查询结果中没有车辆信息的时候,捷径会返回一个错误信息,有时候是请求失败,但是更多的时候是因为站点还未发车,所以无法获取到车辆信息导致报错,有兴趣的读者可以自行修改解决。

  • ProseMirror指南

    这份指南描述了库中使用的各种概念,以及它们之间的关系。为了获得系统的完整印象,建议按照呈现的顺序通读全文,至少需要阅读到Component部分为止。

    介绍

    ProseMirror为构建富文本编辑器提供了一套工具和概念,使用受WYSWIG(what-you-see-is-what-you-get)启发的用户界面,但是避免了这种编辑风格的陷阱。

    ProseMirror的主要原理是您的代码可以完全控制文档以及文档的处理方式。这里的文档不是指HTML,而是一个自定义数据结构,其中仅包含了您允许包含的元素(以您指定的关系)。所有更新都经过一处地方,你可以在这里对它们检查以及做出反应。

    核心库不是一个简单的嵌入式组件——我们优先考虑模块化和可定制性,而不是难易程度,我们希望在未来,有人会发布基于ProseMirror的嵌入式编辑器。因此,这比起Matchbox car来说更像是一个乐高玩具。

    有四个基本的模块,它们是进行任何编辑所需的,以及许多由官方团队维护的扩展,和第三方模块的状态类似——它们提供了实用的功能,但是你可以选择忽略它们或者使用其他具有类似功能的扩展替换。

    这四个模块是:

    • prosemirror-model 定义了编辑器的文档模型,该数据结构用于描述编辑器的内容
    • prosemirror-state 提供了描述编辑器完整状态的数据结构,包括选区,以及用于从一个状态到下一个状态的事务系统
    • prosemirror-view 实现一个用户界面组件,该组件将给定的编辑器状态显示为浏览器中的可编辑元素,并处理与该元素的用户交互
    • prosemirror-transform 包含用于以可记录和重放方式修改文档的功能,这是状态模块中事务的基础,并且使撤消历史记录和协作编辑成为可能

    此外,在Github ProseMirror的组织下,还有一些用于基本编辑命令快捷键绑定撤销记录输入宏协作编辑,简单文档结构的模块

    事实就是ProseMirror不是分发为一个浏览器能加载的脚本,这意味着你可能需要一些打包工具才能使用它。打包工具可以自动的发现你的脚本依赖,并把他们合并成一个大文件以便于在网页中载入。

    我的第一个编辑器

    乐高积木以这种方式组合在一起,以创建一个简单的编辑器:

    import {schema} from "prosemirror-schema-basic"
    import {EditorState} from "prosemirror-state"
    import {EditorView} from "prosemirror-view"
    
    let state = EditorState.create({schema})
    let view = new EditorView(document.body, {state})
    

    ProseMirror要求你指定一个符合你文档架构,所以第一件事就是导入一个包含基本架构的模块

    然后用该架构创建了一个状态,该状态将生成一个符合这个架构的文档,并在该文档开头生成一个默认的选区。最终,为这个状态生成一个视图,并插入到document.body中。这将把状态的文档渲染成一个可编辑的DOM节点,并且当用户键入文档的时候生成状态的transaction

    这个编辑器现在还不是很有用。如果你键入enter,则什么也不会发生,因为核心库不知道键入enter的时候该做什么。稍后我们将解决这个问题。

    Transactions

    当用户输入时,或者与视图进行交互时,这会产生state transactions。这意味着它并不是就地修改文档并以这种方式隐式更新其状态。与之相反的是,每个变化都会创建一个transaction,它描述了对状态所做的变化,并且可被用来创建一个新的状态,这个状态可以用于更新视图。

    默认情况下,所有这些操作都是在后台进行的,但是您可以通过编写插件或配置视图的钩子。举例来说,这段代码添加了一个dispatchTransaction prop,这会在transaction被创建的时候调用。

    // (Imports omitted)
    
    let state = EditorState.create({schema})
    let view = new EditorView(document.body, {
      state,
      dispatchTransaction(transaction) {
        console.log("Document size went from", transaction.before.content.size,
                    "to", transaction.doc.content.size)
        let newState = view.state.apply(transaction)
        view.updateState(newState)
      }
    })
    

    每个状态更新都必须通过updateState,并且每个正常的编辑更新都将通过调度事务来进行。

    Plugins

    插件被用于以各种方式扩展编辑器的行为和编辑器的状态。有一些相对来说比较简单,就像keymap插件绑定动作到键盘输入。其他涉及的更多,例如历史记录插件,该插件通过观察事务并存储它们的反向操作来实现撤消历史记录,以防用户想要撤消它们。

    让我们添加这两个插件到我们的编辑器以实现撤销和重做的功能:

    // (Omitted repeated imports)
    import {undo, redo, history} from "prosemirror-history"
    import {keymap} from "prosemirror-keymap"
    
    let state = EditorState.create({
      schema,
      plugins: [
        history(),
        keymap({"Mod-z": undo, "Mod-y": redo})
      ]
    })
    let view = new EditorView(document.body, {state})
    

    插件在创建状态的时候注册(因为插件需要访问状态事务)。在为这个开启历史记录的状态创建视图之后,你将可以通过按下Ctrl-Z(或者在macOS里是Cmd-Z)来撤销上一次改动。

    Commands

    上一个例子使用的undoredo是一种被叫做命令的特殊方法。大多数编辑动作可以被写成命令以用于绑定到按键,通过菜单调用,或者暴露给用户。

    prosemirror-commands包提供了一些基础的编辑命令,以及最小可用的按键映射,您可能希望启用该键盘映射以便enterdelete这样的动作可以在编辑器中生效。

    // (Omitted repeated imports)
    import {baseKeymap} from "prosemirror-commands"
    
    let state = EditorState.create({
      schema,
      plugins: [
        history(),
        keymap({"Mod-z": undo, "Mod-y": redo}),
        keymap(baseKeymap)
      ]
    })
    let view = new EditorView(document.body, {state})
    

    至此,您已经有了一个基本可用的编辑器。

    要添加菜单,用于架构特定内容的其他键绑定等,您可能需要查看prosemirror-example-setup程序包。这个模块提供给你一系列用于构建基础编辑器的插件,但是就像它的名字建议的一样,这意味着这更多的是一个例子而不是一个可用于生产环境的库。对于实际部署,你可能需要用自定义代码替换它,以完全按照所需的方式进行设置。

    Content

    状态的文档在它的下面的doc属性中。这是一个只读的数据结构,把文档表示为节点的层次结构,有点像浏览器的DOM。一个简单的文档可能是一个doc节点包含两个p节点,每个p节点还包含一个简单的text节点。你可以在指南中阅读更多关于文档数据结构的内容。

    当初始化一个状态,你可以给它一个初始化的文档。在这中情况下,schema是可选的,因为可以从文档中获取schema

    在这里我们通过解析在DOM中ID名为content的初始化状态,通过使用DOM解析机制,该机制使用该schema提供的有关DOM节点映射的信息。

    import {DOMParser} from "prosemirror-model"
    import {EditorState} from "prosemirror-state"
    import {schema} from "prosemirror-schema-basic"
    
    let content = document.getElementById("content")
    let state = EditorState.create({
      doc: DOMParser.fromSchema(schema).parse(content)
    })

    Documents | 文档

    ProseMirror定义了自己的数据结构来表示文档内容。由于文档是构建编辑器所依赖的中心元素,因此了解文档的工作方式将很有帮助。

    Structure

    ProseMirror的文档是一个节点,其中有一个包含零个或更多节点的片段。

    这很像浏览器的DOM,因为它是递归的并且是树形的。但是和DOM不同的地方在于它存储内联内容的方式。

    在HTML里,一个带标记的段落被呈现为一个树形,像这样:

    <p>This is <strong>strong text with <em>emphasis</em></strong></p>
    

    !https://s3-us-west-2.amazonaws.com/secure.notion-static.com/21ffe68d-3aac-42cd-a4fa-4f886e0ca1fb/Screen_Shot_2021-05-05_at_22.31.53.png

    然而在ProseMirror里,内联内容被建模为平面序列,并将标记作为元数据附加到节点:

    !https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d3800986-36bd-42f7-ad3b-c12eb26d73e2/Screen_Shot_2021-05-05_at_22.34.01.png

    这与我们倾向于思考和处理此类文本的方式更加接近。这允许我们通过字符的偏移量表达段落中的位置而不是通过树中的路径,并且使执行类似分割内容或者更改内容的样式更简单,而不需要通过操纵树。

    这也意味着每个文档只有一个合法的呈现。邻近的拥有相同样式的文本节点总是会合并在一起,并且不允许空白的文本节点。标记的显示顺序由schema来决定

    所以一个ProseMirror文档是一棵充满块节点的树,并且大多数叶子节点是被称作文本块的包含文本的块节点。你也可以有空的叶子节点,例如一个水平横条或者一个视频元素。

    节点对象具有许多属性,这些属性反映了它们在文档中扮演的角色:

    • isBlockisInline 告诉你是内联还是块节点
    • 对于希望将内联节点作为内容的节点 inlineContenttrue
    • 块节点包含内联内容的节点 isTextblocktrue
    • isLeaftrue告诉你节点不允许有任何内容

    所以一个典型的p节点将是一个文本节点,因此一个引用将是一个由其他块组成的块元素。文本,hard breaks,内联图像都是内联叶子节点,水平横条节点将是一个块叶子节点

    schema允许对可能出现在何处的内容指定更多精确的约束—i.e.尽管一个节点允许块内容,但那不意味着允许所有的块节点作为内容

    Identity and persistence身份和持久化

    DOM树和ProseMirror文档之间另一个重要的区别是节点的表现行为。在DOM树中,节点是具有身份的可变对象,这意味着一个节点只能有一个父节点,并且在对象更新的时候,节点会发生变化。

    在ProseMirror中,反过来说,节点是简单的值,应该像处理代表数字3的值那样处理节点。数字3可以同时在多个数据结构中出现,它没有父链接到它所属的数据结构,并且如果你给它加1,你会获得一个新的值4,原来的3不会有任何改变。

    对于ProseMirror的文档来说也是如此。它们不会改变,但是可以用来作为计算修改后文档的起点。它们不知道它们属于什么数据结构,它们可以是多个数据结构的一部分,甚至可以在一个数据结构中出现多次。它们是值,而不是有状态的对象。

    这意味着你每次更新文档,你都会得到一个新的文档(值)。这个文档会共享所有没有发生变化的子节点,这使创建新的文档相对更快。

    这样做有很多优势。由于在新状态下可以立即交换带有新文档的新状态,因此在更新期间使编辑器处于无效的中间状态是不可能的。这也使以某种数学方式对文档进行推理变得更加容易,如果你的文档(值)不断变化,这将变得很难。这有助于使协作编辑成为可能,并允许ProseMirror通过将其绘制到屏幕上的最后一个文档与当前文档进行比较来运行非常有效的DOM更新算法。

    由于此类节点由常规JavaScript对象表示,并且显式冻结其属性会影响性能,因此实际上可以更改它们。但是ProseMirror不支持你这么做,如果你这么做,会导致发生崩溃,因为它们通常会在许多不同的数据结构之间共享。所以要小心!并且注意,这也同样适用于属于节点对象的数组和普通对象,例如用于存储节点属性的对象或片段中的子节点数组。

    Data structures 数据结构

    文档的对象结构长得像这样:

    !https://s3-us-west-2.amazonaws.com/secure.notion-static.com/5212f8b2-d159-4266-8e82-415854588a24/WX20210506-2310482x.png

    每个节点表示为一个Node类的实例。它们用type属性进行归类, 通过type属性可以知道节点的名字, 它可以使用的attributes,诸如此类的信息。Node Types(Mark Types)只会被每个schema创建一次,并且知道它们属于哪个schema

    节点的内容被存储在一个叫Fragment的实例中,里面保存了一系列的节点。哪怕节点没有内容或者不允许有内容,这个字段也会被填充(被填充为一个共享的空 Fragment

    一些节点允许存储额外的属性。例如,一个图片节点需要使用属性保存alt文本和图片URL。

    此外,内联节点保存了一系列激活的 mark ——像强调或者链接——它们被保存在一个包含 mark 实例的数组里面。

    完整的文档只是一个节点。文档内容由顶级节点的子节点表示。通常来说,它会包含一系列的块节点,其中一些可能是包含内联节点的textblock。但是顶级节点也可以是一个 textblock ,所以这个文档只包含内联节点。

    一个文档可以包含什么类型的节点由文档的 schema 决定。想要用编程的方式创建节点,你必须要通过 schema ,例如使用 node 或者 text 方法。

    import {schema} from "prosemirror-schema-basic"
    
    // (The null arguments are where you can specify attributes, if necessary.)
    let doc = schema.node("doc", null, [
      schema.node("paragraph", null, [schema.text("One.")]),
      schema.node("horizontal_rule"),
      schema.node("paragraph", null, [schema.text("Two!")])
    ])
    

    Indexing

    ProseMirror支持两种索引方式——以树的方式处理,通过偏移量访问每一个节点,或者处理为一个扁平的 token 序列。

    第一种和你处理DOM的方式很像——和单个节点交互,通过 child methodchildCount 直接访问子节点,通过编写递归方法扫描整个文档(如果你想访问所有节点,使用descendants 或者nodesBetween

    当你在访问一个特定位置的时候第二种更有用。它允许把文档中的所有位置表示为一个整数—— token 索引。这些标记实际上不作为内存中的对象存在——它们只是一种计数约定——但是文档的树形形状以及每个节点知道其大小使得按位置来访问他们变得很容易。

    • 文档的开头,在第一个元素前面的位置是0
    • 进入或离开不是叶节点(i.e.没有内容的节点)的节点都算一个 token 。如果一个文档以 paragraph 开始,这个段落的开头被计数为1
    • 文本节点中的每个字符都算一个 token 。如果在开头的段落内容是”hi“,那么位置2在”h“后面,位置3在”i“后面,位置4在整个段落后面。
    • 不允许有内容的叶节点(比如图片节点)计数为一个 token

    如果你有一个文档,表示为html是这样的:

    <p>One</p>
    <blockquote><p>Two<img src="..."></p></blockquote>
    

    这个token序列的位置是这样的:

    0   1 2 3 4    5
     <p> O n e </p>
    
    5            6   7 8 9 10    11   12            13
     <blockquote> <p> T w o <img> </p> </blockquote>
    

    每个节点都有一个 nodeSize 属性可以告诉你整个节点的大小,并且你可以通过访问 .content.size 或者这个节点内容的大小。请注意,对于外部文档节点,打开和关闭的token不被算作文档的一部分(因为你不能把光标放在文档外面),所以文档的大小是 doc.content.size ,而不是 doc.nodeSize

    手动解释这些位置需要大量的计算。你可以调用 Node.resolve 获得一个位置更具描述性的 data structure。这个数据结构会告诉你父节点的位置,它在父节点里面的位置是多少,父节点有什么祖先,以及更多其他的东西。

    注意区分子索引(childCount),全文范围的位置和节点局部的偏移(有时在递归函数中使用,以表示当前正在处理的节点中的位置)

    Slices

    为了像复制-粘贴以及拖-放一样处理,有必要讨论一下文档的切片问题,i.e. 两个位置之间的内容。这样的slice和一个完整的节点或者节点中的部分片段不同,因为这个切片有可能包含未闭合的节点。

    例如,如果你从一个段落的中间开始选择到下一个段落的中间,你选择的slice中有两个段落,第一个段落缺少头标签,第二个段落缺少尾标签,然后如果你根据节点去选择一个段落,你选择的是一个完整的闭合的节点。如果将此类不完整节点中的内容视为该节点的全部内容,则可能会违反schema约束,因为某些必需的节点不在slice之内。

    Slice的数据结构就是为了表示这种slice 。它用fragment 结构保存了两侧的 open depth 。你可以在节点上用 slice methodslice从文档中取出来。

    // doc holds two paragraphs, containing text "a" and "b"
    let slice1 = doc.slice(0, 3) // The first paragraph
    console.log(slice1.openStart, slice1.openEnd) // → 0 0
    let slice2 = doc.slice(1, 5) // From start of first paragraph
                                 // to end of second
    console.log(slice2.openStart, slice2.openEnd) // → 1 1
    

    Changing

    由于nodefragment是持久性的,因此您绝对不应对其进行改变。如果你有一个文档的句柄(一个node或者fragment),它永远不会发生改变。

    大多数时候,你会使用transformations 去更新文档,并且不会直接接触节点。这会留下一个变更的记录,这对一个编辑器的state来说是必需的。

    如果你想手动的导出更新后的文档,那么你可能会需要NodeFragment上一些方法的帮助。为了创建一个更新版本的文档,你可能需要使用Node.replace ,它用一个新的slice中的内容替换了给定范围的文档。你可以使用copy方法浅度的更新一个节点,它会创建一个拥有新内容的相似的节点。Fragments还有很多其他用于更新的方法,比如replaceChildappend