React App - Markdown Editor Sync Scroll

背景

编辑器的主要性能和要解决的问题上一篇已经大概记录完了, 最近又碰见一个比较有意思的问题, 这里也记录一下

主要问题就是在编辑器写完一部分代码之后, 滚动到下一部分的时候, 右边的渲染内容也需要同步滚动. 常见的流行编辑器都实现了这个功能, 但是很少有描述如何实现的, 看了一个编辑器实现的源码, 它是基于解析的时候记录行号和渲染行号来实现的同步滚动, 这个对于我们使用开源的marked来说非常不友好. 于是自己研究实现了一个基于header渲染的滚动同步算法, 运行后效果也与外界基本一致, 也不存在性能问题

实现原理

既然我们无法在渲染时获取当前哪行和渲染后是哪一行, 那么退一步, 获取当前在第几个header下, 以及在此header下的偏移位置(用百分比来控制), 编辑框内容就直接用正则/^\s*(#{1,6})\s/.exec(line.text)来获取header的位置

计算编辑器左边坐标

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const layoutRef = useRef<{
left: { from: number; to: number; tocId: string }[];
right: { from: number; to: number; tocId: string }[];
}>({ left: [], right: [] });

const calcLeftLayout = () => {
try {
// get left position
const levelNIndex: Record<number, number> = {};

let codeStart = false;

layoutRef.current.left = [];

let leftProTocContentLineHeight = 0;
let leftCurrentTocContentLineHeight = 0;
let leftPreTocId: string = null;

codeMirrorRef.current.editor.eachLine((line) => {
if (line.text.indexOf('```') > -1) {
codeStart = !codeStart;
}
if (codeStart) {
leftCurrentTocContentLineHeight += (line as any).height;
return;
}
const matches = /^\s*(#{1,6})\s/.exec(line.text);
if (matches) {
const level = matches[1].length;
if (!levelNIndex[level]) {
levelNIndex[level] = 1;
} else {
levelNIndex[level] = levelNIndex[level] + 1;
}
layoutRef.current.left.push({
from: leftProTocContentLineHeight + 1,
to: leftCurrentTocContentLineHeight,
tocId: leftPreTocId,
});
leftPreTocId = `t-${level}-${levelNIndex[level]}`;
leftProTocContentLineHeight = leftCurrentTocContentLineHeight;
}
leftCurrentTocContentLineHeight += (line as any).height;
});
layoutRef.current.left.push({
from: leftProTocContentLineHeight,
to: leftCurrentTocContentLineHeight,
tocId: leftPreTocId,
});
} catch (e) {
console.log(e);
}
};

const throttleCalcLeftLayout = useCallback(_.throttle(calcLeftLayout, 200), []);

计算右侧渲染坐标

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
const calcRightLayout = () => {
try {
// get right position
const tocs = previewDivRef.current.querySelectorAll<HTMLDivElement>('.toc-id');

layoutRef.current.right = [];
let rightPreLine = 0;
let rightPreTocId = null;
for (let i = 0; i < tocs.length; i++) {
layoutRef.current.right.push({
from: rightPreLine,
to: tocs[i].offsetTop,
tocId: rightPreTocId,
});
rightPreLine = tocs[i].offsetTop + 1;
rightPreTocId = tocs[i].id;
}
layoutRef.current.right.push({
from: rightPreLine,
to: previewDivRef.current.scrollHeight,
tocId: rightPreTocId,
});
const imgs = previewDivRef.current.querySelectorAll<HTMLImageElement>('img');
_.map(imgs, (img) => img.addEventListener('load', calcRightLayout));
} catch (e) {
console.log(e);
}
};

同步滚动实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
const throttleScroll = useMemo(
() => ({
onLeftScroll: _.throttle((editor: CodeMirror.Editor) => {
// left scrollTop
const leftScrollTop: number = (editor.getDoc() as any).scrollTop;

if (scrollInfoValueRef.current.rightScrollEvent) {
scrollInfoValueRef.current.rightScrollEvent = false;
return false;
}
scrollInfoValueRef.current.leftScrollEvent = true;

if (leftScrollTop === 0) {
previewDivRef.current.scrollTop = 0;
return;
}
// calc left view [top] percent

const leftItemIndex = _.findIndex(layoutRef.current.left, (item) => leftScrollTop >= item.from && leftScrollTop <= item.to);

if (leftItemIndex === -1) {
return;
}
const leftItem = layoutRef.current.left[leftItemIndex];

const percent = (leftScrollTop - leftItem.from) / (leftItem.to - leftItem.from);

// calc right view [top] px
const rightItem = layoutRef.current.right[leftItemIndex];
if (!rightItem) {
console.warn(`rightItem undefined, leftItemIndex:${leftItemIndex}`);
return;
}

previewDivRef.current.scrollTop = rightItem.from + (rightItem.to - rightItem.from) * percent;
}, 0),
onRightScroll: _.throttle((event: React.UIEvent<HTMLDivElement, UIEvent>) => {
// right scrollTop
const rightScrollTop = previewDivRef.current.scrollTop;
const rightScrollHeight = previewDivRef.current.scrollHeight;
const rightClientHeight = previewDivRef.current.clientHeight;

if (scrollInfoValueRef.current.leftScrollEvent) {
scrollInfoValueRef.current.leftScrollEvent = false;
return false;
}
scrollInfoValueRef.current.rightScrollEvent = true;

if (rightScrollTop === 0) {
codeMirrorRef.current.editor.scrollTo(undefined, 0);
return;
}

// calc right view [top] percent

const rightItemIndex = _.findIndex(layoutRef.current.right, (item) => rightScrollTop >= item.from && rightScrollTop <= item.to);

if (rightItemIndex === -1) {
return;
}
const rightItem = layoutRef.current.right[rightItemIndex];

const percent = (rightScrollTop - rightItem.from) / (rightItem.to - rightItem.from);

// calc left view [top] px

const leftItem = layoutRef.current.left[rightItemIndex];
if (!leftItem) {
console.warn(`leftItem undefined, rightItemIndex:${rightItemIndex}`);
return;
}

// 右侧预览已经在底部时,不需要再触发左侧的滚动
if (rightScrollHeight - rightScrollTop === rightClientHeight) {
return;
}

codeMirrorRef.current.editor.scrollTo(undefined, leftItem.from + (leftItem.to - leftItem.from) * percent);
}, 0),
}),
[],
);

const previewDivRef = useRef<HTMLDivElement>(null);
const editorDivRef = useRef<HTMLDivElement>(null);

滚动方法使用

codemirror

1
2
3
4
5
6
7
8
9
10
<CodeMirror
editorDidMount={(editor: CodeMirror.Editor) => (codeMirrorRef.current.editor = editor)}
className='content-editor-code-mirror'
onScroll={(editor) => {
if (!editorStateRef.current.showPreview) {
return;
}
throttleScroll.onLeftScroll(editor);
}}
/>

preview

1
2
3
<div className={classNames('doc-markdown-container')} ref={previewDivRef} onScroll={throttleScroll.onRightScroll}>
<MarkdownRenderWrapper content={editorStateRef.current.rightView.content} onClickLink={clickLink} />
</div>

小结

这次使用一种次优的方案实现了比较好的滚动效果, 整体比较节省时间和工作量, 其中最麻烦的是双向滚动时候的状态保存, 需要记录滚动是由左侧还是右侧触发的, 再计算需要滚动到的对应坐标. 最终使用百分比, 实现了在mermaid和大图片的情况下滚动都是基本同步无异常的效果

当然最好的方案还是渲染时直接记录对应行号, 但是这样成本太高只能舍弃了

作者

Mosby

发布于

2020-06-23

许可协议

评论