背景
编辑器的主要性能和要解决的问题上一篇已经大概记录完了, 最近又碰见一个比较有意思的问题, 这里也记录一下
主要问题就是在编辑器写完一部分代码之后, 滚动到下一部分的时候, 右边的渲染内容也需要同步滚动. 常见的流行编辑器都实现了这个功能, 但是很少有描述如何实现的, 看了一个编辑器实现的源码, 它是基于解析的时候记录行号和渲染行号来实现的同步滚动, 这个对于我们使用开源的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 { 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 { 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) => { 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; }
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);
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>) => { 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; }
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);
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
和大图片的情况下滚动都是基本同步无异常的效果
当然最好的方案还是渲染时直接记录对应行号, 但是这样成本太高只能舍弃了