之前用code-mirror些了个便签, 最近又开始想复习找工作, 用起来还差点意思.

markdown比较适合写作, 用在”便签”系统, 在需要”给列表打钩”来表示完成状态的时候就没招了.

而我这复习列表正是需要给列表标注状态.

这里记录实现思路, 再结合上次存放图片的功能聊聊对decoration的认识.

需求和思路

与存放图片功能的类似, 希望选中一段文本, 快捷键操作让他改变背景颜色, 并且产生对应的标签语法, 因为毕竟在markdown环境中.

于是需求就拆分成了2个:

  • 快捷键给选中的文本增加标签, 我这里暂定的是这样的标签:#(数字)#(内容)$$ 其中数字表示颜色序号.

    (这里我直接使用了标签的9个颜色主题, 他们是被仔细选过与文字颜色对比和谐的, 也已经处理过黑暗模式)

  • 给有对应标签语法的文字背景样式.

实现

实现这2个功能都是通过extension.

keybinding

先来说keybinding, 我希望 cmd + 数字1~9 来触发, 但cm的keybinding只能指定按键, 于是就直接绑定9个按键.

1
[1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => getKeyBinding(i))

getKeyBinding()方法里返回一个KeyBinding对象.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const getKeyBinding: (i: number) => KeyBinding = (i) => {
return {
key: `Mod-${i}`,
run: (view: EditorView) => {
view.dispatch(view.state.changeByRange(range => {
let content = view.state.doc.sliceString(range.from, range.to)
const originLenth = content.length;
const isMatched = /^#(\d)#.*\$\$$/.exec(content)
if (isMatched) {
if (isMatched[1] === i.toString()) {
content = content.replace(/^#\d#(.*)\$\$$/, '$1');
} else {
content = content.replace(/^#\d#(.*)\$\$$/, '#' + i.toString() + '#' + '$1' + '$$$');
}
} else {
content = `#${i}#${content}$$`;
}
return {
changes: [{ from: range.from, to: range.to, insert: content }],
range: EditorSelection.range(range.from, range.to - originLenth + content.length),
}
}))
return true;
}
}
}
  1. 通过api获取到选中的文本content.
  2. 判断文本content的情况, 对content进行处理.
  3. 通过api把修改的content插入会文本.

修改content的情况分为这3种:

  1. 正则不命中, 则是普通文本, 那就在文本两侧加上标记.
  2. 通过正则取出”标记数字”如果和目标数字相同, 则取消标记.
  3. 如果和目标数字不同, 则把数字修改为目标数字.

通过标记语法修改背景颜色

插件的入口和插入图片的入口是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
const bgRenderer = ViewPlugin.fromClass(class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = createBgDecro(view)
}
update(update: any) {
if (update.docChanged || update.viewportChanged)
this.decorations = createBgDecro(update.view)
}
}, {
decorations: (v) => v.decorations,
})

这是控制在初始化和更新的时候重新渲染decoration.

下面是decoration的主体部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const createBgDecro = (view: EditorView) => {
const text = [...view.state.doc].join('')
// #1#content$$
const bgRegex = /#(\d)#(.+)(\$\$)/g
let builder = new RangeSetBuilder<Decoration>()
let isMatched: boolean | null | RegExpExecArray = true;
while (isMatched) {
isMatched = bgRegex.exec(text);
if (isMatched) {
// bg color
builder.add(isMatched.index, isMatched.index + isMatched[0].length, Decoration.mark({
attributes: {
style: `background-color: var(--sticky-${isMatched[1]})`
}
}))
// prefix small text
builder.add(isMatched.index, isMatched.index + 3, Decoration.mark({
attributes: {
style: `font-size: 10px`
}
}))
// postfix small text
builder.add(isMatched.index + isMatched[0].length - 2, isMatched.index + isMatched[0].length, Decoration.mark({
attributes: {
style: `font-size: 10px`
}
}))
}
}
return builder.finish()
}

createBgDecro()接受EditorView参数, 获取当前文档内容, 返回一个decoration.

这里的decoration和extension一样, 可以返回数组, cm在处理的时候会先flatten.

RangeSetBuilder()的作用就是最后返回[decoration, decoration, decoration], 上次做显示图片功能的时候就是直接返回的.

文档对decoration的介绍解释了不能直接操作dom, 是无效的, 要通过decoration来改变样式或者增加一些表现.

decoration类是继承于rangeValue类的, 所以decoration必须会有range信息, 再根据不同的decoration类型有一些额外的属性.

decoration的类型都是由decoration的静态方法创建的. 我们这里用到了mark, 是给指定的range加上一些attr.

之前显示图片功能用到的是widget, 因为图片需要增加dom, 光修改样式不够.

加载

我们把2个写好的extension加载到配置里就完成了.

1
export const blockbg = [bgRenderer, keymap.of([1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => getKeyBinding(i)))]
1
2
3
4
5
6
7
8
9
10
11
let startState = EditorState.create({
doc: init(),
extensions: [
// ...
blockbg,
]
})
new EditorView({
state: startState,
parent: dom(),
}).focus()