WARNING: IT'S NOT COMPLETED YET AND IN A VERY RAW STATUS, I WILL UPDATE WHEN I WANT, SO DON'T TRY TO USE IT, AND DON'T FORK IT(YOU CAN STAR OR WATCH IT IF YOU ARE INTEREST IN FOLLOW-UP DEVELOPMENT).
A Notion's editor implement based on ProseMirror, just for feasibility studies.
基础 schema 是两个 doc 和 text, 这是 Prosemirror 默认的两个最大和最小可编辑 schema. 而设计 schema 的时候我使用的最小编辑单元是 textblock, 表现形式是一个 div 中包含着 text, 比如:
<div>一些文本</div>
所有的元素都是使用 div 进行模拟, 而不是使用语义化的 p/ul/ol 等进行, 这是为了摆脱浏览器的限制, 如段落嵌套段落的时候, p 标签无法嵌套块级元素等.
按照元素范围的大小可以有以下表示:
text < textblock < block < section
block 类型的元素有: paragraph, blockquote, list, heading, divider, codeblock
section 是一个可以包含 block 的 div, 主要用途是在水平分隔多列上, 以让每个 section 包含多个 block. section 类型的元素有: section
<div type="paragraph">
<div>一些文本</div>
</div>
此时, paragraph 有两个容器, 一个是 textblock 类型, 还一个是 block 类型
<div type="paragraph">
<div>一些文本</div>
<div type="block-type"></div>
</div>
此外, 段落的缩进有两种情况, 一个是当前段落是顶级元素, 则缩进时使用样式来实现; 如果当前段落之前也有段落或者 list, 则 tab 后会将带段落变成上一个段落/list 的 block 容器中的元素
<div type="list">
<div>1.</div>
<div>
<div>一些文本</div>
</div>
</div>
此时, list 有两个容器, 一个是 textblock 类型, 还一个是 block 类型
<div type="list">
<div>1.</div>
<div>
<div>一些文本</div>
<div type="block-type"></div>
</div>
</div>
<div type="block-type">
<div>一些文本</div>
</div>
table 也是用 div 模拟. TODO
预计使用 embeded 元素实现, 嵌入三方视频需要商业付费, TODO
<img type="image" />
<div type="block-type"></div>
<div type="section">
<div type="block-type"></div>
</div>
此处参照 Notion 交互.
- 不允许跨 block 选择部分文本内容. 如, 无法选择其中一个 block 的后半部分内容后, 再选中下一个 block 的前半部分内容. 如果先选中了当前 block 的后半部分内容后, 鼠标不松开滑动到下一个 block 意图选中其前半部分, 则效果是两个 block 都被整体选中.
keymap 文件定义了整个编辑器的功能键的交互行为, 此处统一处理, 不再将文件分散到各个 module 通过返回 true/false 来处理.
- 如果是光标, 会直接在当前 block 下新建一个同级的 paragraph 元素, 除非有特殊情况(见下).
- 如果是选中了内容, 则会将内容删除, 然后再下面新建一个同级的 paragraph 元素, 除非有特殊情况(见下).
- 如果选中了内容, 且内容后还有文本, 则会将选中内容删除, 然后将选区后的内容放到下一个 paragraph 中, 除非有特殊情况(见下).
- 如果选中了非原子节点的整个 node 节点, 则什么也不做, 取消选区.
- 如果选择了原子节点, 则在原子节点之后新建一个同级的 paragraph.
会在当前 section 下新建一个同类型的 list block, 光标/选区/选区后还有内容的情况与一般情况相同.
会直接换行, 光标/选区/选区后还有内容的情况与一般情况相同.
回车键按下后执行选中的插入命令. 插入位置是跟当前 textblock 父级节点同级
- 如果是光标, 则删除光标前的一个字符
- 如果是选区, 则删除选区内容
- 如果光标在文本的最前方按下 backspace, 则属于复杂情况
- 如果光标所在的 textblock 父级 block 是 paragraph, 且与 paragraph 同级的上一个 block 是 list, 则寻找 list 的最后一个 textblock 的 end 位置(该位置可能在 list 的 block 容器中, 也可能直接就是 list 的 textblock), 将光标所在的文本块的文本内容附加到 end 之后, 然后删除文本块及其父级 block.
- 注意, 如果上一个 list 还有其子 block 类型的容器, 则重复相同步步骤, 直到找到 block 类型容器中的 textblock
- 注意, 如果 paragraph 含有其自己的 block 容器, 则在 textblock 内容附加到上一行后, 将其 block 容器内容每个"提升一级"
- 如果光标所在的 paragraph 本身处于 list 的 block 容器中, 则首先将本 paragraph 提高到和其父级 list 同级, 如果仍然在 list 的 block 容器中则继续, 直到其为 doc 的直接子元素, 然后执行步骤 1.
- 如果光标所在的 textblock 的父级是 list, 则将该 list 变为 paragraph, 保留 textblock 内容, 同时如果该 list 有 block 容器, 则将该容器作为 paragraph 的 block 容器
- 如果光标所在的 textblock 的父级是 paragraph, 且其前一个同级 block 也是 paragraph, 则将光标所在的 textblock 的文本放到 上一个 paragraph 中的最后一个 textblock 之后.
- 注意: 如果光标所在的 textblock 的父级 paragraph 还有 block 容器, 则将容器内容每个"提升一级"
- 如果光标所在的 textblock 的父级 paragraph 本身也在另一个 paragraph 的 block 容器中, 则其将会先变成同级关系.
- 首先将该 block 变成 paragraph, 然后按照上述两种情况处理
- Notion 有个特别交互, 有如下结构:
<ol>
<li>
<div>1. </div>
<div>
<div textblock>
abc
</div>
<div block>
<div p1>
<div textblock>def</div>
</div>
<div p2>
<div textblock>ghi</div>
</div>
<div p3>
<div textblock>klm</div>
</div>
</div>
</div>
</li>
</ol>
则在 d 之前按 backspace 时, def 会拼接到 abc 之后, 然后剩余的两个 ghi 和 klm 不变
在 g 之前按 backspace 时, 同样, ghi 会拼接到 def 之后, 然后剩余的 kml 不变
但是在 k 之前按 backspace 时, klm 会往前反缩进, 变成与 li 同级的 p 标签.
即: 在内嵌的 block 容器中(li 中或者 p 中都允许存在)的最后一个
p 标签开始位置按下删除键, 则会取消缩进(反缩进), 变成与父级同级的元素. 但是在非最后位置的 p 标签中的开始位置按下删除键, 则只是将其附加到上一个 textblock 最后. 如果 最后一个也是第一个元素
, 则最后一个
逻辑优先, 即其会取消缩进.
其实这是一个 feature: 用户在一个 list 中, 按下回车后会继承当前行的缩进和类型, 如上例中, 在 abc 后按回车, 则新建一个 list. 而在 klm 后按回车, 则继续在 block 容器中新建一个 p 标签. 如果用户此时或者在最后一行输入文字后在行首按下 backspace 多半是想要取消缩进跳出 list 的 block 容器, 而不是想要回到上一行 paragraph 中.
- 当 slash 弹窗出来的时候控制选中的命令, 否则走系统默认
使用了 React 构建界面的有: Slash
之前实现弹窗等界面的方案:
- 使用 typebehind 触发 slash plugin 的 setMeta, 信息为 slash 符号的起始和结束位置
- 在 slash plugin 的 apply 中 getMeta, 拿到然后设置 plugin state, 同时进行新旧 start 和 end 的对比, 同时获取 slash 符号起始位置直到新的 state selection 的 end 位置的字符 filtertext
- 拿到 start 和 end 和 filtertext 字符之后, slash plugin 的 view 就会据此渲染 react 组件, 同时通过 coordsAtPos 知道输入 slash 和 filtertext 的位置, 显示出来
- react 组件有自己的 list, 每输入一个字符, slash plugin 的 view 的 update 方法就调用一次, react list 就会通过通过正则 filtertext 过滤部分的 list, 然后显示.
上述基本数据流是:
prosemirror
typebehind ---> prosemirror
plugin set meta ---> prosemirror
plugin state update ---> prosemirror
view update ---> react
component update(with new plugin state)
此方案有以下问题:
- 我想在 keymap 中当 slash 弹窗出现时使用 ArrowUp/ArrowDown 来选择某个 list 由于每次 react list 都会随着 变得较为困难
改进后实现弹窗界面的方案:
- 在 typebehind 中 setMeta slash plugin
- 在 slash plugin 的 apply 返回 state 来组建 ProseMirror 的 state 的时候, 使用 redux 的 dispatch 来触发 react 的界面构建, 而不是使用 view 的界面