From c0819c9d785cc95b9f3641c30bb86fe1a08739d1 Mon Sep 17 00:00:00 2001 From: xiaotianna <127941140+xiaotianna@users.noreply.github.com> Date: Sat, 25 Jan 2025 04:36:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(mini-markdown-editor):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E4=BB=A3=E7=A0=81,=20=E4=BF=AE=E5=A4=8Dcopy?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=8D=E6=BB=9A=E5=8A=A8,=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=AD=E9=97=B4=E5=86=85=E5=AE=B9=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=97=B6=E9=A2=84=E8=A7=88=E5=8C=BA=E4=B9=B1=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Editor/index.tsx | 31 +- ...\347\202\271\350\267\237\351\232\217].tsx" | 186 ------------ .../src/components/Preview/index.tsx | 28 +- .../mini-markdown-editor/src/store/editor.ts | 6 - .../src/utils/handle-scroll.ts | 282 ++++++++---------- 5 files changed, 163 insertions(+), 370 deletions(-) delete mode 100644 "packages/mini-markdown-editor/src/components/Editor/index[v1.0.1 \350\212\202\347\202\271\350\267\237\351\232\217].tsx" diff --git a/packages/mini-markdown-editor/src/components/Editor/index.tsx b/packages/mini-markdown-editor/src/components/Editor/index.tsx index 7091004..8816e68 100644 --- a/packages/mini-markdown-editor/src/components/Editor/index.tsx +++ b/packages/mini-markdown-editor/src/components/Editor/index.tsx @@ -1,6 +1,6 @@ -import { FC, useEffect, useRef } from "react"; +import { FC, useCallback, useEffect, useRef } from "react"; import styled from "styled-components"; -import CodeMirror, { type EditorView, ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import CodeMirror, { type EditorView, ViewUpdate } from "@uiw/react-codemirror"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { languages } from "@codemirror/language-data"; import * as events from "@uiw/codemirror-extensions-events"; @@ -42,13 +42,21 @@ const Editor: FC = () => { setContent, scrollWrapper, setScrollWrapper, - editorView, setEditorView, previewView, - setEditorRef, + editorView, } = useEditorContentStore(); - const editorRef = useRef(null); const localStorage = safeLocalStorage(); + // ref转发 + const editorViewRef = useRef(); + // 存储实例 + const setEditorViewInstance = useCallback( + (view: EditorView) => { + setEditorView(view); + editorViewRef.current = view; + }, + [editorViewRef], + ); // 处理重加载后的光标位置 useEffect(() => { @@ -62,20 +70,24 @@ const Editor: FC = () => { // 编辑器挂载完成后将编辑器示例存储起来 const handleCreate = (view: EditorView) => { - setEditorView(view); - setEditorRef(editorRef); + setEditorViewInstance(view); }; - const handleChange = (val: string) => { + const handleChange = (val: string, editView: ViewUpdate) => { + // 更新store setContent(val); // 本地同步存储 localStorage.setItem(EDITOR_CONTENT_KEY, val); + // 更新编辑器实例 + setEditorViewInstance(editView.view); }; const eventExt = events.scroll({ scroll: () => { if (scrollWrapper !== "editor") return; - handleEditorScroll(editorRef, previewView); + const view = editorViewRef.current; + if (!view || !previewView) return; + handleEditorScroll({ editorView: view, previewView }); }, }); @@ -87,7 +99,6 @@ const Editor: FC = () => { { - const { content, setContent, scrollWrapper, setScrollWrapper, setEditorView, previewView } = - useEditorContentStore(); - const editorRef = useRef(null); - - // 编辑器挂载完成后将编辑器示例存储起来 - const handleCreate = (view: EditorView) => { - setEditorView(view); - }; - - 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(); - }, - }); - - const handleMouseEnter = () => { - setScrollWrapper("editor"); - }; - - return ( - - - - ); -}; - -export default Editor; diff --git a/packages/mini-markdown-editor/src/components/Preview/index.tsx b/packages/mini-markdown-editor/src/components/Preview/index.tsx index 80eac4e..8a87e94 100644 --- a/packages/mini-markdown-editor/src/components/Preview/index.tsx +++ b/packages/mini-markdown-editor/src/components/Preview/index.tsx @@ -5,6 +5,7 @@ import "highlight.js/styles/atom-one-dark.css"; import styled from "styled-components"; import { useEditorContentStore } from "@/store/editor"; import { handlePreviewScroll } from "@/utils/handle-scroll"; +import React from "react"; const ScrollWrapper = styled.div` width: 100%; @@ -14,27 +15,34 @@ const ScrollWrapper = styled.div` `; const Preview: FC<{ content: string }> = ({ content }) => { - const ast = parseMarkdown(content); - const node = transformHtml(ast); + // store + const { scrollWrapper, setScrollWrapper, setPreviewView, editorView } = useEditorContentStore(); + + // 渲染 html 节点 + const node = React.useMemo(() => { + const ast = parseMarkdown(content); + return transformHtml(ast); + }, [content]); const previewRef = useRef(null); - const { scrollWrapper, setScrollWrapper, setPreviewView, editorRef } = useEditorContentStore(); + // 更新渲染实例 + useEffect(() => { + if (previewRef.current && node) { + setPreviewView(previewRef.current); + } + }, [node]); const handleScroll = (e: React.UIEvent) => { if (scrollWrapper !== "preview") return; - handlePreviewScroll(e.currentTarget, editorRef!); + const previewView = e.currentTarget; + if (!editorView || !previewView) return; + handlePreviewScroll({ previewView, editorView }); }; const handleMoseEnter = () => { setScrollWrapper("preview"); }; - useEffect(() => { - if (previewRef.current && node) { - setPreviewView(previewRef.current); - } - }, [node]); - return ( // className='markdown-editor-preview' 重置样式的节点 void; - editorRef: React.RefObject | null; - setEditorRef: (ref: React.RefObject) => void; focusEditor: () => void; // 预览区 previewView: HTMLElement | null; @@ -24,7 +21,6 @@ const localStorage = safeLocalStorage(); // 编辑器内容状态 const useEditorContentStore = create((set, get) => ({ - // content: code, content: localStorage.getItem(EDITOR_CONTENT_KEY) || "", setContent: (content: string) => set({ content }), scrollWrapper: "", @@ -32,8 +28,6 @@ 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 index 8bacd53..ae76a1c 100644 --- a/packages/mini-markdown-editor/src/utils/handle-scroll.ts +++ b/packages/mini-markdown-editor/src/utils/handle-scroll.ts @@ -1,180 +1,146 @@ -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); - }); -}; +import { EditorView } from "@uiw/react-codemirror"; -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; - } +interface InstancesType { + previewView: HTMLElement; + editorView: EditorView; +} + +class Scroll { + // 用于存放编辑器和预览区高度对应关系 + editorElementList: number[]; + previewElementList: number[]; + + constructor() { + this.editorElementList = []; + this.previewElementList = []; } - // 编辑区域已经滚动到底部,那么预览区域也直接滚动到底部 - 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); + // 计算编辑器和预览区域高度的对应关系 + computedTop({ previewView, editorView }: InstancesType) { + const nodeArr = Array.from(previewView!.childNodes).filter((n: ChildNode) => { + if ((n as HTMLElement).clientHeight === 0 && n.nodeName === "P") { + return; } - } - - requestAnimationFrame(step); - return; + return n; + }); + this.editorElementList = []; + this.previewElementList = []; + nodeArr.forEach((node) => { + const lineAtr = (node as HTMLElement).getAttribute("data-line"); + if (!lineAtr) return; + // 预览区元素对应编辑区行号 + const lineNumber = Number(lineAtr); + // 确保行号在有效范围内 + if (lineNumber < 1 || !editorView.state?.doc || lineNumber > editorView.state.doc.lines) { + return; + } + // 获取编辑器区域行号 + const line = editorView.state?.doc?.line(lineNumber); + // 获取编辑器区域行高 + const lineBlock = editorView.lineBlockAt(line.from); + // 获取编辑器区域顶部距离 + const topHeight = lineBlock!.top; + this.editorElementList.push(topHeight); + this.previewElementList.push((node as HTMLElement).offsetTop); + }); } - if (scrollElementIndex < editorElementList.length - 1) { - const currentEditorPos = editorElementList[scrollElementIndex]; - const nextEditorPos = editorElementList[scrollElementIndex + 1]; - const currentPreviewPos = previewElementList[scrollElementIndex]; - const nextPreviewPos = previewElementList[scrollElementIndex + 1]; + // 处理滚动事件 + handleScroll(source: "editor" | "preview", { editorView, previewView }: InstancesType) { + const editorInstance = editorView; + const scrollInfo = source === "editor" ? editorInstance.scrollDOM : previewView; + const targetElement = source === "editor" ? previewView : editorInstance.scrollDOM; - // 计算滚动比例时考虑元素高度 - const editorDistance = nextEditorPos - currentEditorPos; - const previewDistance = nextPreviewPos - currentPreviewPos; + if (!scrollInfo || !targetElement) return; - // 添加最小距离阈值,避免小距离计算导致的跳动 - const MIN_DISTANCE = 10; - if (editorDistance < MIN_DISTANCE || previewDistance < MIN_DISTANCE) { - return; + // 找到当前滚动位置对应的节点索引 + const sourceList = source === "editor" ? this.editorElementList : this.previewElementList; + const targetList = source === "editor" ? this.previewElementList : this.editorElementList; + + let scrollElementIndex = sourceList.length - 1; + for (let i = 0; i < sourceList.length - 1; i++) { + if (scrollInfo.scrollTop < sourceList[i + 1]) { + scrollElementIndex = i; + break; + } } - // 计算滚动比例 - const ratio = Math.max( - 0, - Math.min(1, (editorScrollInfo.scrollTop - currentEditorPos) / editorDistance), - ); - requestAnimationFrame(() => { - previewView.scrollTop = currentPreviewPos + previewDistance * ratio; - }); - } -}; + // 源区域已经滚动到底部,那么目标区域也直接滚动到底部 + if (scrollInfo.scrollTop >= scrollInfo.scrollHeight - scrollInfo.clientHeight) { + const targetScrollTop = targetElement.scrollHeight - targetElement.clientHeight; + const currentScrollTop = targetElement.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); + // 确保 targetElement 存在再进行滚动操作 + if (targetElement) { + targetElement.scrollTop = currentScrollTop + distance * percent; + if (time < duration) { + requestAnimationFrame(step); + } + } + } -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; + requestAnimationFrame(step); + return; } - } - // 预览区域已经滚动到底部,那么编辑区域也直接滚动到底部 - 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); + // 目标区域滚动到对应位置 + if (scrollElementIndex < sourceList.length - 1) { + const currentSourcePos = sourceList[scrollElementIndex]; + const nextSourcePos = sourceList[scrollElementIndex + 1]; + const currentTargetPos = targetList[scrollElementIndex]; + const nextTargetPos = targetList[scrollElementIndex + 1]; + + // 计算滚动比例时考虑元素高度 + const sourceDistance = nextSourcePos - currentSourcePos; + const targetDistance = nextTargetPos - currentTargetPos; + + // 添加最小距离阈值,避免小距离计算导致的跳动 + const MIN_DISTANCE = 10; + if (sourceDistance < MIN_DISTANCE || targetDistance < MIN_DISTANCE) { + return; } - } - requestAnimationFrame(step); - return; + // 计算滚动比例 + const ratio = Math.max( + 0, + Math.min(1, (scrollInfo.scrollTop - currentSourcePos) / sourceDistance), + ); + requestAnimationFrame(() => { + targetElement.scrollTop = currentTargetPos + targetDistance * ratio; + }); + } } - 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; + // 编辑区滚动 + handleEditorScroll(editorView: EditorView, previewView: HTMLElement | null) { + if (previewView) { + this.computedTop({ previewView, editorView }); + this.handleScroll("editor", { editorView, previewView }); } + } - // 计算滚动比例 - const ratio = Math.max( - 0, - Math.min(1, (previewScrollInfo.scrollTop - currentPreviewPos) / previewDistance), - ); - requestAnimationFrame(() => { - editorScrollInfo.scrollTop = currentEditorPos + editorDistance * ratio; - }); + // 预览区滚动 + handlePreviewScroll(previewView: HTMLElement | null, editorView: EditorView) { + if (previewView) { + this.computedTop({ previewView, editorView }); + this.handleScroll("preview", { editorView, previewView }); + } } +} + +export const scroll = new Scroll(); + +export const handleEditorScroll = ({ editorView, previewView }: InstancesType) => { + scroll.handleEditorScroll(editorView, previewView); }; -export { handleEditorScroll, handlePreviewScroll }; +export const handlePreviewScroll = ({ editorView, previewView }: InstancesType) => { + scroll.handlePreviewScroll(previewView, editorView); +}; -- Gitee