From 1a9da1434a6796802ebeebe88794eac7693f22e7 Mon Sep 17 00:00:00 2001 From: xiaotianna <127941140+xiaotianna@users.noreply.github.com> Date: Fri, 24 Jan 2025 09:47:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(mini-markdown-editor):=20=E5=8F=8C?= =?UTF-8?q?=E5=B1=8F=E6=BB=9A=E5=8A=A8(=E6=9C=AA=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E7=89=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Editor/index.tsx | 114 ++--------- .../src/components/Preview/index.tsx | 9 +- .../mini-markdown-editor/src/store/editor.ts | 5 + .../src/utils/handle-scroll.ts | 180 ++++++++++++++++++ .../src/utils/scroll-sync.ts | 31 ++- 5 files changed, 216 insertions(+), 123 deletions(-) create mode 100644 packages/mini-markdown-editor/src/utils/handle-scroll.ts diff --git a/packages/mini-markdown-editor/src/components/Editor/index.tsx b/packages/mini-markdown-editor/src/components/Editor/index.tsx index 1628685..b789a64 100644 --- a/packages/mini-markdown-editor/src/components/Editor/index.tsx +++ b/packages/mini-markdown-editor/src/components/Editor/index.tsx @@ -5,6 +5,7 @@ import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { languages } from "@codemirror/language-data"; import * as events from "@uiw/codemirror-extensions-events"; import { useEditorContentStore } from "@/store/editor"; +import { handleEditorScroll } from "@/utils/handle-scroll"; const ScrollWrapper = styled.div` width: 100%; @@ -34,120 +35,31 @@ const ScrollWrapper = styled.div` `; const Editor: FC = () => { - const { content, setContent, scrollWrapper, setScrollWrapper, setEditorView, previewView } = - useEditorContentStore(); + const { + content, + setContent, + scrollWrapper, + setScrollWrapper, + setEditorView, + previewView, + setEditorRef, + } = useEditorContentStore(); const editorRef = useRef(null); // 编辑器挂载完成后将编辑器示例存储起来 const handleCreate = (view: EditorView) => { setEditorView(view); + setEditorRef(editorRef); }; const handleChange = (val: string) => { setContent(val); }; - const handleScroll = () => { - let editorElementList: number[] = []; - let previewElementList: number[] = []; - const computedTop = () => { - const nodeArr = Array.from(previewView!.childNodes).filter((n: ChildNode) => { - if ((n as HTMLElement).clientHeight === 0 && n.nodeName === "P") { - return; - } - return n; - }); - editorElementList = []; - previewElementList = []; - const editorInstance = editorRef.current; - nodeArr.forEach((node) => { - const lineAtr = (node as HTMLElement).getAttribute("data-line"); - if (!lineAtr) return; - const lineNumber = Number(lineAtr); - // 确保行号在有效范围内 - if ( - lineNumber < 1 || - !editorInstance?.state?.doc || - lineNumber > editorInstance.state.doc.lines - ) { - return; - } - const line = editorInstance?.state?.doc?.line(lineNumber); - const lineBlock = editorInstance?.view?.lineBlockAt(line!.from); - const topHeight = lineBlock!.top; - editorElementList.push(topHeight); - previewElementList.push((node as HTMLElement).offsetTop); - }); - }; - computedTop(); - - const editorScrollInfo = editorRef.current?.view?.scrollDOM; - if (!editorScrollInfo || !previewView) return; - - // 找到当前滚动位置对应的节点索引 - let scrollElementIndex = editorElementList.length - 1; - for (let i = 0; i < editorElementList.length - 1; i++) { - if (editorScrollInfo.scrollTop < editorElementList[i + 1]) { - scrollElementIndex = i; - break; - } - } - - // 编辑区域已经滚动到底部,那么预览区域也直接滚动到底部 - if ( - editorScrollInfo.scrollTop >= - editorScrollInfo.scrollHeight - editorScrollInfo.clientHeight - ) { - const targetScrollTop = previewView.scrollHeight - previewView.clientHeight; - const currentScrollTop = previewView.scrollTop; - const distance = targetScrollTop - currentScrollTop; - const duration = 100; // 滚动动画持续时间,单位毫秒 - let start: number; - - function step(timestamp: number) { - if (start === undefined) start = timestamp; - const time = timestamp - start; - const percent = Math.min(time / duration, 1); - previewView!.scrollTop = currentScrollTop + distance * percent; - if (time < duration) { - requestAnimationFrame(step); - } - } - - requestAnimationFrame(step); - return; - } - - if (scrollElementIndex < editorElementList.length - 1) { - const currentEditorPos = editorElementList[scrollElementIndex]; - const nextEditorPos = editorElementList[scrollElementIndex + 1]; - const currentPreviewPos = previewElementList[scrollElementIndex]; - const nextPreviewPos = previewElementList[scrollElementIndex + 1]; - - // 计算滚动比例时考虑元素高度 - const editorDistance = nextEditorPos - currentEditorPos; - const previewDistance = nextPreviewPos - currentPreviewPos; - - // 添加最小距离阈值,避免小距离计算导致的跳动 - const MIN_DISTANCE = 10; - if (editorDistance < MIN_DISTANCE || previewDistance < MIN_DISTANCE) { - return; - } - - // 计算滚动比例 - const ratio = Math.max( - 0, - Math.min(1, (editorScrollInfo.scrollTop - currentEditorPos) / editorDistance), - ); - requestAnimationFrame(() => { - previewView.scrollTop = currentPreviewPos + previewDistance * ratio; - }); - } - }; - const eventExt = events.scroll({ scroll: () => { - handleScroll(); + if (scrollWrapper !== "editor") return; + handleEditorScroll(editorRef, previewView); }, }); diff --git a/packages/mini-markdown-editor/src/components/Preview/index.tsx b/packages/mini-markdown-editor/src/components/Preview/index.tsx index 36d4fec..80eac4e 100644 --- a/packages/mini-markdown-editor/src/components/Preview/index.tsx +++ b/packages/mini-markdown-editor/src/components/Preview/index.tsx @@ -3,8 +3,8 @@ import { parseMarkdown, transformHtml } from "@mini-markdown/ast-parser"; import "@/assets/styles/preview.css"; import "highlight.js/styles/atom-one-dark.css"; import styled from "styled-components"; -import { scrollSync } from "@/utils/scroll-sync"; import { useEditorContentStore } from "@/store/editor"; +import { handlePreviewScroll } from "@/utils/handle-scroll"; const ScrollWrapper = styled.div` width: 100%; @@ -18,14 +18,11 @@ const Preview: FC<{ content: string }> = ({ content }) => { const node = transformHtml(ast); const previewRef = useRef(null); - const { scrollWrapper, setScrollWrapper, setPreviewView } = useEditorContentStore(); + const { scrollWrapper, setScrollWrapper, setPreviewView, editorRef } = useEditorContentStore(); const handleScroll = (e: React.UIEvent) => { if (scrollWrapper !== "preview") return; - scrollSync({ - toScrollInstance: e.currentTarget, - fromScrollInstance: document.querySelector(".cm-scroller"), - }); + handlePreviewScroll(e.currentTarget, editorRef!); }; const handleMoseEnter = () => { diff --git a/packages/mini-markdown-editor/src/store/editor.ts b/packages/mini-markdown-editor/src/store/editor.ts index 70dbc62..1584cda 100644 --- a/packages/mini-markdown-editor/src/store/editor.ts +++ b/packages/mini-markdown-editor/src/store/editor.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import code from "@/mock/preview.md?raw"; import type { EditorView } from "@codemirror/view"; +import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; interface EditorContentStoreType { content: string; @@ -10,6 +11,8 @@ interface EditorContentStoreType { // 编辑区 editorView: EditorView | null; setEditorView: (view: EditorView | null) => void; + editorRef: React.RefObject | null; + setEditorRef: (ref: React.RefObject) => void; focusEditor: () => void; // 预览区 previewView: HTMLElement | null; @@ -25,6 +28,8 @@ const useEditorContentStore = create((set, get) => ({ // 编辑区 editorView: null, setEditorView: (view: EditorView | null) => set({ editorView: view }), + editorRef: null, + setEditorRef: (ref: React.RefObject) => set({ editorRef: ref }), focusEditor: () => { const { editorView } = get(); if (editorView) { diff --git a/packages/mini-markdown-editor/src/utils/handle-scroll.ts b/packages/mini-markdown-editor/src/utils/handle-scroll.ts new file mode 100644 index 0000000..8bacd53 --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/handle-scroll.ts @@ -0,0 +1,180 @@ +import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; + +let editorElementList: number[] = []; +let previewElementList: number[] = []; + +const computedTop = ({ + previewView, + editorRef, +}: { + previewView: HTMLElement | null; + editorRef: React.RefObject; +}) => { + const nodeArr = Array.from(previewView!.childNodes).filter((n: ChildNode) => { + if ((n as HTMLElement).clientHeight === 0 && n.nodeName === "P") { + return; + } + return n; + }); + editorElementList = []; + previewElementList = []; + const editorInstance = editorRef.current; + nodeArr.forEach((node) => { + const lineAtr = (node as HTMLElement).getAttribute("data-line"); + if (!lineAtr) return; + const lineNumber = Number(lineAtr); + // 确保行号在有效范围内 + if ( + lineNumber < 1 || + !editorInstance?.state?.doc || + lineNumber > editorInstance.state.doc.lines + ) { + return; + } + const line = editorInstance?.state?.doc?.line(lineNumber); + const lineBlock = editorInstance?.view?.lineBlockAt(line!.from); + const topHeight = lineBlock!.top; + editorElementList.push(topHeight); + previewElementList.push((node as HTMLElement).offsetTop); + }); +}; + +const handleEditorScroll = ( + editorRef: React.RefObject, + previewView: HTMLElement | null, +) => { + computedTop({ editorRef, previewView }); + + const editorScrollInfo = editorRef.current?.view?.scrollDOM; + if (!editorScrollInfo || !previewView) return; + + // 找到当前滚动位置对应的节点索引 + let scrollElementIndex = editorElementList.length - 1; + for (let i = 0; i < editorElementList.length - 1; i++) { + if (editorScrollInfo.scrollTop < editorElementList[i + 1]) { + scrollElementIndex = i; + break; + } + } + + // 编辑区域已经滚动到底部,那么预览区域也直接滚动到底部 + if (editorScrollInfo.scrollTop >= editorScrollInfo.scrollHeight - editorScrollInfo.clientHeight) { + const targetScrollTop = previewView.scrollHeight - previewView.clientHeight; + const currentScrollTop = previewView.scrollTop; + const distance = targetScrollTop - currentScrollTop; + const duration = 100; // 滚动动画持续时间,单位毫秒 + let start: number; + + function step(timestamp: number) { + if (start === undefined) start = timestamp; + const time = timestamp - start; + const percent = Math.min(time / duration, 1); + previewView!.scrollTop = currentScrollTop + distance * percent; + if (time < duration) { + requestAnimationFrame(step); + } + } + + requestAnimationFrame(step); + return; + } + + if (scrollElementIndex < editorElementList.length - 1) { + const currentEditorPos = editorElementList[scrollElementIndex]; + const nextEditorPos = editorElementList[scrollElementIndex + 1]; + const currentPreviewPos = previewElementList[scrollElementIndex]; + const nextPreviewPos = previewElementList[scrollElementIndex + 1]; + + // 计算滚动比例时考虑元素高度 + const editorDistance = nextEditorPos - currentEditorPos; + const previewDistance = nextPreviewPos - currentPreviewPos; + + // 添加最小距离阈值,避免小距离计算导致的跳动 + const MIN_DISTANCE = 10; + if (editorDistance < MIN_DISTANCE || previewDistance < MIN_DISTANCE) { + return; + } + + // 计算滚动比例 + const ratio = Math.max( + 0, + Math.min(1, (editorScrollInfo.scrollTop - currentEditorPos) / editorDistance), + ); + requestAnimationFrame(() => { + previewView.scrollTop = currentPreviewPos + previewDistance * ratio; + }); + } +}; + +const handlePreviewScroll = ( + previewView: HTMLElement | null, + editorRef: React.RefObject, +) => { + computedTop({ previewView, editorRef }); + + const previewScrollInfo = previewView; + const editorScrollInfo = editorRef.current?.view?.scrollDOM; + if (!previewScrollInfo || !editorScrollInfo) return; + + // 找到当前滚动位置对应的节点索引 + let scrollElementIndex = previewElementList.length - 1; + for (let i = 0; i < previewElementList.length - 1; i++) { + if (previewScrollInfo.scrollTop < previewElementList[i + 1]) { + scrollElementIndex = i; + break; + } + } + + // 预览区域已经滚动到底部,那么编辑区域也直接滚动到底部 + if ( + previewScrollInfo.scrollTop >= + previewScrollInfo.scrollHeight - previewScrollInfo.clientHeight + ) { + const targetScrollTop = editorScrollInfo.scrollHeight - editorScrollInfo.clientHeight; + const currentScrollTop = editorScrollInfo.scrollTop; + const distance = targetScrollTop - currentScrollTop; + const duration = 100; // 滚动动画持续时间,单位毫秒 + let start: number; + + function step(timestamp: number) { + if (start === undefined) start = timestamp; + const time = timestamp - start; + const percent = Math.min(time / duration, 1); + editorScrollInfo!.scrollTop = currentScrollTop + distance * percent; + if (time < duration) { + requestAnimationFrame(step); + } + } + + requestAnimationFrame(step); + return; + } + + if (scrollElementIndex < previewElementList.length - 1) { + const currentPreviewPos = previewElementList[scrollElementIndex]; + const nextPreviewPos = previewElementList[scrollElementIndex + 1]; + const currentEditorPos = editorElementList[scrollElementIndex]; + const nextEditorPos = editorElementList[scrollElementIndex + 1]; + + // 计算滚动比例时考虑元素高度 + const previewDistance = nextPreviewPos - currentPreviewPos; + const editorDistance = nextEditorPos - currentEditorPos; + + // 添加最小距离阈值,避免小距离计算导致的跳动 + const MIN_DISTANCE = 10; + if (previewDistance < MIN_DISTANCE || editorDistance < MIN_DISTANCE) { + return; + } + + // 计算滚动比例 + const ratio = Math.max( + 0, + Math.min(1, (previewScrollInfo.scrollTop - currentPreviewPos) / previewDistance), + ); + requestAnimationFrame(() => { + editorScrollInfo.scrollTop = currentEditorPos + editorDistance * ratio; + }); + } +}; + +export { handleEditorScroll, handlePreviewScroll }; diff --git a/packages/mini-markdown-editor/src/utils/scroll-sync.ts b/packages/mini-markdown-editor/src/utils/scroll-sync.ts index a67c754..b4ea49e 100644 --- a/packages/mini-markdown-editor/src/utils/scroll-sync.ts +++ b/packages/mini-markdown-editor/src/utils/scroll-sync.ts @@ -1,26 +1,25 @@ -// 同步滚动 +// 同步滚动(比例滚动) const scrollSyncFn = () => { - let isSyncing = false + let isSyncing = false; return ({ toScrollInstance, // 触发滚动的实例 fromScrollInstance, // 同步滚动的实例 }: { - toScrollInstance: HTMLElement | null - fromScrollInstance: HTMLElement | null + toScrollInstance: HTMLElement | null; + fromScrollInstance: HTMLElement | null; }) => { - if (isSyncing) return - isSyncing = true + if (isSyncing) return; + isSyncing = true; if (toScrollInstance && fromScrollInstance) { - const scrollTop = toScrollInstance.scrollTop - const scrollHeight = toScrollInstance.scrollHeight - const clientHeight = toScrollInstance.clientHeight || 1 // 可视区域高度 - const scrollPercentage = scrollTop / (scrollHeight - clientHeight) // 滚动百分比 + const scrollTop = toScrollInstance.scrollTop; + const scrollHeight = toScrollInstance.scrollHeight; + const clientHeight = toScrollInstance.clientHeight || 1; // 可视区域高度 + const scrollPercentage = scrollTop / (scrollHeight - clientHeight); // 滚动百分比 fromScrollInstance!.scrollTop = - scrollPercentage * - (fromScrollInstance!.scrollHeight - fromScrollInstance!.clientHeight) + scrollPercentage * (fromScrollInstance!.scrollHeight - fromScrollInstance!.clientHeight); } - isSyncing = false - } -} + isSyncing = false; + }; +}; -export const scrollSync = scrollSyncFn() +export const scrollSync = scrollSyncFn(); -- Gitee