From 9ab9e1fbe65fc3826db1c896c8303628f9620a98 Mon Sep 17 00:00:00 2001 From: xiaotianna <127941140+xiaotianna@users.noreply.github.com> Date: Fri, 24 Jan 2025 08:07:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(mini-markdown-editor):=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=8C=BA=E5=90=8C=E6=AD=A5=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mini-markdown-editor/eslint.config.js | 26 ++- .../src/EditorWrapper.tsx | 5 +- .../src/components/Editor/index.tsx | 119 +++++++++-- ...\344\276\213\346\273\232\345\212\250].tsx" | 103 ++++++++++ ...\347\202\271\350\267\237\351\232\217].tsx" | 186 ++++++++++++++++++ .../src/components/Preview/index.tsx | 17 +- .../mini-markdown-editor/src/mock/preview.md | 14 ++ .../mini-markdown-editor/src/store/editor.ts | 10 +- 8 files changed, 439 insertions(+), 41 deletions(-) create mode 100644 "packages/mini-markdown-editor/src/components/Editor/index[v1.0.0 \346\257\224\344\276\213\346\273\232\345\212\250].tsx" create 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/eslint.config.js b/packages/mini-markdown-editor/eslint.config.js index 092408a..8b74ae1 100644 --- a/packages/mini-markdown-editor/eslint.config.js +++ b/packages/mini-markdown-editor/eslint.config.js @@ -1,28 +1,26 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ["dist"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "react-hooks/exhaustive-deps": "off", }, }, -) +); diff --git a/packages/mini-markdown-editor/src/EditorWrapper.tsx b/packages/mini-markdown-editor/src/EditorWrapper.tsx index 919ad17..6016474 100644 --- a/packages/mini-markdown-editor/src/EditorWrapper.tsx +++ b/packages/mini-markdown-editor/src/EditorWrapper.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useDeferredValue } from "react"; import styled from "styled-components"; import { useEditorContentStore } from "@/store/editor"; import Toolbar from "@/components/Toolbar"; @@ -54,6 +54,7 @@ const Divider = styled.div` const EditorWrapper: FC = () => { const { content } = useEditorContentStore(); + const deferredContent = useDeferredValue(content); return ( @@ -69,7 +70,7 @@ const EditorWrapper: FC = () => { {/* 渲染区 */} - + {/* 分割线 */} diff --git a/packages/mini-markdown-editor/src/components/Editor/index.tsx b/packages/mini-markdown-editor/src/components/Editor/index.tsx index b5f7a04..1628685 100644 --- a/packages/mini-markdown-editor/src/components/Editor/index.tsx +++ b/packages/mini-markdown-editor/src/components/Editor/index.tsx @@ -5,8 +5,6 @@ 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 { scrollSync } from "@/utils/scroll-sync"; -import { useDebounceFn } from "ahooks"; const ScrollWrapper = styled.div` width: 100%; @@ -36,7 +34,7 @@ const ScrollWrapper = styled.div` `; const Editor: FC = () => { - const { content, setContent, scrollWrapper, setScrollWrapper, setEditorView } = + const { content, setContent, scrollWrapper, setScrollWrapper, setEditorView, previewView } = useEditorContentStore(); const editorRef = useRef(null); @@ -45,26 +43,111 @@ const Editor: FC = () => { setEditorView(view); }; - const { run } = useDebounceFn( - () => { - const editorInstance = editorRef.current?.view?.dom; - if (editorInstance) { - scrollSync({ - toScrollInstance: editorInstance, - fromScrollInstance: document.querySelector(".markdown-editor-preview"), - }); - } - }, - { wait: 10 }, - ); - 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: (e: Event) => { - run(e); + scroll: () => { + handleScroll(); }, }); diff --git "a/packages/mini-markdown-editor/src/components/Editor/index[v1.0.0 \346\257\224\344\276\213\346\273\232\345\212\250].tsx" "b/packages/mini-markdown-editor/src/components/Editor/index[v1.0.0 \346\257\224\344\276\213\346\273\232\345\212\250].tsx" new file mode 100644 index 0000000..5f38257 --- /dev/null +++ "b/packages/mini-markdown-editor/src/components/Editor/index[v1.0.0 \346\257\224\344\276\213\346\273\232\345\212\250].tsx" @@ -0,0 +1,103 @@ +import { FC, useRef } from "react"; +import styled from "styled-components"; +import CodeMirror, { type EditorView, ReactCodeMirrorRef } 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"; +import { useEditorContentStore } from "@/store/editor"; +import { scrollSync } from "@/utils/scroll-sync"; +import { useDebounceFn } from "ahooks"; + +const ScrollWrapper = styled.div` + width: 100%; + height: 100%; + overflow: auto; + padding: 5px 10px; + display: flex; + flex-direction: column; + + .editor-content { + width: 100%; + min-height: 100%; + font-size: 16px; + line-height: 24px; + outline: none; + } + .cm-editor { + outline: none; + height: 100%; + } + .cm-editor.cm-focused { + outline: none; + } + .cm-scroller { + height: 100%; + } +`; + +const Editor: FC = () => { + const { content, setContent, scrollWrapper, setScrollWrapper, setEditorView } = + useEditorContentStore(); + const editorRef = useRef(null); + + // 编辑器挂载完成后将编辑器示例存储起来 + const handleCreate = (view: EditorView) => { + setEditorView(view); + }; + + const { run } = useDebounceFn( + () => { + const editorInstance = editorRef.current?.view?.scrollDOM; + if (editorInstance) { + scrollSync({ + toScrollInstance: editorInstance, + fromScrollInstance: document.querySelector(".markdown-editor-preview"), + }); + } + }, + { wait: 10 }, + ); + + const handleChange = (val: string) => { + setContent(val); + }; + + const eventExt = events.scroll({ + scroll: (e: Event) => { + run(e); + }, + }); + + const handleMouseEnter = () => { + setScrollWrapper("editor"); + }; + + return ( + + + + ); +}; + +export default Editor; diff --git "a/packages/mini-markdown-editor/src/components/Editor/index[v1.0.1 \350\212\202\347\202\271\350\267\237\351\232\217].tsx" "b/packages/mini-markdown-editor/src/components/Editor/index[v1.0.1 \350\212\202\347\202\271\350\267\237\351\232\217].tsx" new file mode 100644 index 0000000..1628685 --- /dev/null +++ "b/packages/mini-markdown-editor/src/components/Editor/index[v1.0.1 \350\212\202\347\202\271\350\267\237\351\232\217].tsx" @@ -0,0 +1,186 @@ +import { FC, useRef } from "react"; +import styled from "styled-components"; +import CodeMirror, { type EditorView, ReactCodeMirrorRef } 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"; +import { useEditorContentStore } from "@/store/editor"; + +const ScrollWrapper = styled.div` + width: 100%; + height: 100%; + overflow: auto; + padding: 5px 10px; + display: flex; + flex-direction: column; + + .editor-content { + width: 100%; + min-height: 100%; + font-size: 16px; + line-height: 24px; + outline: none; + } + .cm-editor { + outline: none; + height: 100%; + } + .cm-editor.cm-focused { + outline: none; + } + .cm-scroller { + height: 100%; + } +`; + +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 d700f83..36d4fec 100644 --- a/packages/mini-markdown-editor/src/components/Preview/index.tsx +++ b/packages/mini-markdown-editor/src/components/Preview/index.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useEffect, useRef } from "react"; import { parseMarkdown, transformHtml } from "@mini-markdown/ast-parser"; import "@/assets/styles/preview.css"; import "highlight.js/styles/atom-one-dark.css"; @@ -16,8 +16,9 @@ const ScrollWrapper = styled.div` const Preview: FC<{ content: string }> = ({ content }) => { const ast = parseMarkdown(content); const node = transformHtml(ast); + const previewRef = useRef(null); - const { scrollWrapper, setScrollWrapper } = useEditorContentStore(); + const { scrollWrapper, setScrollWrapper, setPreviewView } = useEditorContentStore(); const handleScroll = (e: React.UIEvent) => { if (scrollWrapper !== "preview") return; @@ -31,15 +32,21 @@ const Preview: FC<{ content: string }> = ({ content }) => { setScrollWrapper("preview"); }; + useEffect(() => { + if (previewRef.current && node) { + setPreviewView(previewRef.current); + } + }, [node]); + return ( // className='markdown-editor-preview' 重置样式的节点 -
-
+ ref={previewRef} + dangerouslySetInnerHTML={{ __html: node.toString() }} + > ); }; export default Preview; diff --git a/packages/mini-markdown-editor/src/mock/preview.md b/packages/mini-markdown-editor/src/mock/preview.md index 02855ed..02c72a4 100644 --- a/packages/mini-markdown-editor/src/mock/preview.md +++ b/packages/mini-markdown-editor/src/mock/preview.md @@ -20,6 +20,19 @@ asd`code``code`asd--uder--~~bolang~~ > > blockquote _bolang_ +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) +![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) ![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) ![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) @@ -84,6 +97,7 @@ asd`code``code`asd--uder--~~bolang~~ ![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) ![image](https://www.baidu.com/img/flexible/logo/pc/result@2.png) + - 1 - asd - 啊啥的、2 diff --git a/packages/mini-markdown-editor/src/store/editor.ts b/packages/mini-markdown-editor/src/store/editor.ts index 36ebcf1..70dbc62 100644 --- a/packages/mini-markdown-editor/src/store/editor.ts +++ b/packages/mini-markdown-editor/src/store/editor.ts @@ -7,10 +7,13 @@ interface EditorContentStoreType { setContent: (content: string) => void; scrollWrapper: string; setScrollWrapper: (scrollWrapper: string) => void; - + // 编辑区 editorView: EditorView | null; setEditorView: (view: EditorView | null) => void; focusEditor: () => void; + // 预览区 + previewView: HTMLElement | null; + setPreviewView: (view: HTMLElement | null) => void; } // 编辑器内容状态 @@ -19,7 +22,7 @@ const useEditorContentStore = create((set, get) => ({ setContent: (content: string) => set({ content }), scrollWrapper: "", setScrollWrapper: (scrollWrapper: string) => set({ scrollWrapper }), - + // 编辑区 editorView: null, setEditorView: (view: EditorView | null) => set({ editorView: view }), focusEditor: () => { @@ -28,6 +31,9 @@ const useEditorContentStore = create((set, get) => ({ editorView.focus(); } }, + // 预览区 + previewView: null, + setPreviewView: (view: HTMLElement | null) => set({ previewView: view }), })); export { useEditorContentStore }; -- Gitee