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 1/6] =?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 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 2/6] =?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 From 3d46ba982bf9dfb1d44523f7967f0ec5da79bbd5 Mon Sep 17 00:00:00 2001 From: xiaotianna <127941140+xiaotianna@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:57:03 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(mini-markdown-ast-parser):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8Dast=E8=A7=A3=E6=9E=90=E7=9A=84data-line=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E6=B2=A1=E6=8E=A8=E9=80=81=E7=9A=84bug,=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=9E=84=E5=BB=BA=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/transform/render/blockquote.ts | 12 ++++---- .../src/core/transform/render/code.ts | 30 +++++++++---------- .../src/core/transform/render/heading.ts | 12 ++++---- .../src/core/transform/render/list.ts | 10 +++---- .../src/core/transform/render/paragraph.ts | 8 ++--- .../src/core/transform/render/table.ts | 10 +++---- .../core/transform/render/thematicBreak.ts | 8 ++--- packages/mini-markdown-editor/package.json | 3 +- 8 files changed, 47 insertions(+), 46 deletions(-) diff --git a/packages/mini-markdown-ast-parser/src/core/transform/render/blockquote.ts b/packages/mini-markdown-ast-parser/src/core/transform/render/blockquote.ts index 7ac36ea..361f258 100644 --- a/packages/mini-markdown-ast-parser/src/core/transform/render/blockquote.ts +++ b/packages/mini-markdown-ast-parser/src/core/transform/render/blockquote.ts @@ -1,9 +1,9 @@ -import { Tokens } from '@/types/tokens' -import { astToHtml } from '..' -import { prefix } from '@/common/constant' +import { Tokens } from "@/types/tokens"; +import { astToHtml } from ".."; +import { prefix } from "@/common/constant"; export const renderBlockquote = (node: Tokens) => { - return `
${node.children + return `
${node.children ?.map(astToHtml) - .join('')}
` -} + .join("")}
`; +}; diff --git a/packages/mini-markdown-ast-parser/src/core/transform/render/code.ts b/packages/mini-markdown-ast-parser/src/core/transform/render/code.ts index bee52a0..44f5daf 100644 --- a/packages/mini-markdown-ast-parser/src/core/transform/render/code.ts +++ b/packages/mini-markdown-ast-parser/src/core/transform/render/code.ts @@ -1,28 +1,28 @@ -import { prefix } from '@/common/constant' -import { Tokens } from '@/types/tokens' -import hljs from 'highlight.js' +import { prefix } from "@/common/constant"; +import { Tokens } from "@/types/tokens"; +import hljs from "highlight.js"; export const renderCode = (node: Tokens) => { - let language = 'plaintext' + let language = "plaintext"; try { // 检查语言是否有效 - const lang = node.lang || 'plaintext' - if (lang !== 'plaintext' && !hljs.getLanguage(lang)) { - node.lang = 'plaintext' + const lang = node.lang || "plaintext"; + if (lang !== "plaintext" && !hljs.getLanguage(lang)) { + node.lang = "plaintext"; } if (node.lang) { - hljs.highlight('', { language: node.lang }) - language = node.lang + hljs.highlight("", { language: node.lang }); + language = node.lang; } } catch (e) { // 如果语言无效,使用plaintext - language = 'plaintext' + language = "plaintext"; } const highlightedCode = hljs.highlight(node.value!, { - language - }).value - return `
+ language, + }).value; + return `
@@ -32,5 +32,5 @@ export const renderCode = (node: Tokens) => {
${language}
${highlightedCode}
-
` -} +
`; +}; diff --git a/packages/mini-markdown-ast-parser/src/core/transform/render/heading.ts b/packages/mini-markdown-ast-parser/src/core/transform/render/heading.ts index c1e8638..168ec91 100644 --- a/packages/mini-markdown-ast-parser/src/core/transform/render/heading.ts +++ b/packages/mini-markdown-ast-parser/src/core/transform/render/heading.ts @@ -1,9 +1,9 @@ -import { Tokens } from '@/types/tokens' -import { astToHtml } from '..' -import { prefix } from '@/common/constant' +import { Tokens } from "@/types/tokens"; +import { astToHtml } from ".."; +import { prefix } from "@/common/constant"; export const renderHeading = (node: Tokens) => { - return `${node.children?.map(astToHtml).join('')}${node.children?.map(astToHtml).join("")}` -} + }>`; +}; diff --git a/packages/mini-markdown-ast-parser/src/core/transform/render/list.ts b/packages/mini-markdown-ast-parser/src/core/transform/render/list.ts index cff6e3a..5d51dac 100644 --- a/packages/mini-markdown-ast-parser/src/core/transform/render/list.ts +++ b/packages/mini-markdown-ast-parser/src/core/transform/render/list.ts @@ -1,7 +1,7 @@ -import { Tokens } from '@/types/tokens' -import { astToHtml } from '..' +import { Tokens } from "@/types/tokens"; +import { astToHtml } from ".."; export const renderList = (node: Tokens) => { - const listType = node.ordered ? 'ol' : 'ul' - return `<${listType}>${node.children?.map(astToHtml).join('')}` -} + const listType = node.ordered ? "ol" : "ul"; + return `<${listType} data-line="${node.position.start.line}">${node.children?.map(astToHtml).join("")}`; +}; diff --git a/packages/mini-markdown-ast-parser/src/core/transform/render/paragraph.ts b/packages/mini-markdown-ast-parser/src/core/transform/render/paragraph.ts index 169a349..0e1e069 100644 --- a/packages/mini-markdown-ast-parser/src/core/transform/render/paragraph.ts +++ b/packages/mini-markdown-ast-parser/src/core/transform/render/paragraph.ts @@ -1,6 +1,6 @@ -import { Tokens } from '@/types/tokens' -import { astToHtml } from '..' +import { Tokens } from "@/types/tokens"; +import { astToHtml } from ".."; export const renderParagraph = (node: Tokens) => { - return `

${node.children?.map(astToHtml).join('')}

` -} + return `

${node.children?.map(astToHtml).join("")}

`; +}; diff --git a/packages/mini-markdown-ast-parser/src/core/transform/render/table.ts b/packages/mini-markdown-ast-parser/src/core/transform/render/table.ts index 04b89e9..1d17ae9 100644 --- a/packages/mini-markdown-ast-parser/src/core/transform/render/table.ts +++ b/packages/mini-markdown-ast-parser/src/core/transform/render/table.ts @@ -1,7 +1,7 @@ -import { Tokens } from '@/types/tokens' -import { astToHtml } from '..' -import { prefix } from '@/common/constant' +import { Tokens } from "@/types/tokens"; +import { astToHtml } from ".."; +import { prefix } from "@/common/constant"; export const renderTable = (node: Tokens) => { - return `${node.children?.map(astToHtml).join('')}
` -} + return `${node.children?.map(astToHtml).join("")}
`; +}; diff --git a/packages/mini-markdown-ast-parser/src/core/transform/render/thematicBreak.ts b/packages/mini-markdown-ast-parser/src/core/transform/render/thematicBreak.ts index da16bf3..b6f320a 100644 --- a/packages/mini-markdown-ast-parser/src/core/transform/render/thematicBreak.ts +++ b/packages/mini-markdown-ast-parser/src/core/transform/render/thematicBreak.ts @@ -1,6 +1,6 @@ -import { prefix } from '@/common/constant' -import { Tokens } from '@/types/tokens' +import { prefix } from "@/common/constant"; +import { Tokens } from "@/types/tokens"; export const renderThematicBreak = (node: Tokens) => { - return `
` -} + return `
`; +}; diff --git a/packages/mini-markdown-editor/package.json b/packages/mini-markdown-editor/package.json index 9d64d59..67ecdb2 100644 --- a/packages/mini-markdown-editor/package.json +++ b/packages/mini-markdown-editor/package.json @@ -4,7 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "build:ast": "pnpm -F @mini-markdown/ast-parser build", + "dev": "pnpm build:ast && vite", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" -- Gitee From 017be5622342a103db020be171b0890b2b4d4b15 Mon Sep 17 00:00:00 2001 From: e1chan <876394202@qq.com> Date: Fri, 24 Jan 2025 16:48:08 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(mini-markdown-ast-parser):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84inline=E8=A7=A3=E6=9E=90=E9=80=BB=E8=BE=91=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BA=86inlineCode=E5=86=85=E9=83=A8=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E4=BB=8D=E4=BC=9A=E8=A2=AB=E8=A7=A3=E6=9E=90=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/parse/compose/inline/index.ts | 446 ++++++++---------- 1 file changed, 208 insertions(+), 238 deletions(-) diff --git a/packages/mini-markdown-ast-parser/src/core/parse/compose/inline/index.ts b/packages/mini-markdown-ast-parser/src/core/parse/compose/inline/index.ts index a02f880..fcc7a74 100644 --- a/packages/mini-markdown-ast-parser/src/core/parse/compose/inline/index.ts +++ b/packages/mini-markdown-ast-parser/src/core/parse/compose/inline/index.ts @@ -1,265 +1,235 @@ -import { Tokens } from "@/types/tokens" +import { Tokens } from "@/types/tokens"; +import { TokenTypeVal } from "@/types/tokens-types"; + +interface MarkdownPattern { + regex: RegExp; + process: (match: RegExpMatchArray, context: ProcessContext) => Tokens; +} + +interface ProcessContext { + line: string; + index: number; + offset: number; + currentOffset: number; + parseInlineElements: ( + line: string, + index: number, + currentOffset: number + ) => Tokens[]; +} + +// 定义所有 Markdown 模式 +const MARKDOWN_PATTERNS: Record = { + bold: { + regex: /\*\*(?.*?)\*\*/, + process: (match, context) => + createStandardToken("bold" as TokenTypeVal, match, context), + }, + italic: { + regex: /\_(?.*?)\_/, + process: (match, context) => + createStandardToken("italic" as TokenTypeVal, match, context), + }, + underline: { + regex: /\-{2}(?.*?)\-{2}/, + process: (match, context) => + createStandardToken("underline" as TokenTypeVal, match, context), + }, + delete: { + regex: /\~{2}(?.*?)\~{2}/, + process: (match, context) => + createStandardToken("delete" as TokenTypeVal, match, context), + }, + inlineCode: { + regex: /`(?.*?)`/, + process: (match, context) => { + if (!match[1].trim()) { + return createTextToken(match[0], match, context); + } + return { + type: "inlineCode", + value: match.groups?.content || match[1], + position: createPosition(match, context), + }; + }, + }, + image: { + regex: /!\[(?.*?)\]\((?.*?)\)/g, + process: (match, context) => ({ + type: "image", + title: null, + url: match.groups?.url || "", + alt: match.groups?.alt || "", + position: createPosition(match, context), + }), + }, + link: { + regex: /\[(?[^\]]+)\]\((?[^)]+)\)/, + process: (match, context) => ({ + type: "link", + title: null, + url: match.groups?.url || "", + children: [createTextToken(match.groups?.text || "", match, context)], + position: createPosition(match, context), + }), + }, + html: { + regex: /<(?[a-zA-Z0-9]+)(?[^>]*)>(?.*?)<\/\1>/g, + process: (match, context) => ({ + type: "html", + value: match[0], + position: createPosition(match, context), + }), + }, +}; + +// 创建位置信息 +function createPosition(match: RegExpMatchArray, context: ProcessContext) { + const startOffset = + context.currentOffset + context.offset + (match.index ?? 0); + const endOffset = startOffset + match[0].length; + + return { + start: { + line: context.index + 1, + column: context.offset + (match.index ?? 0) + 1, + offset: startOffset, + }, + end: { + line: context.index + 1, + column: context.offset + (match.index ?? 0) + match[0].length + 1, + offset: endOffset, + }, + }; +} + +// 创建文本节点 +function createTextToken( + value: string, + match: RegExpMatchArray, + context: ProcessContext +): Tokens { + return { + type: "text", + value, + position: createPosition(match, context), + }; +} + +// 创建标准内联标记节点 +function createStandardToken( + type: TokenTypeVal, + match: RegExpMatchArray, + context: ProcessContext +): Tokens { + const innerContent = match.groups?.content || match[1]; + const innerOffset = + context.currentOffset + + context.offset + + (match.index ?? 0) + + (type === "bold" || type === "delete" ? 2 : 1); + + return { + type, + children: context.parseInlineElements( + innerContent, + context.index, + innerOffset + ), + position: createPosition(match, context), + }; +} + +// 查找最近的匹配 +function findNextMatch(line: string, offset: number) { + let bestMatch: { type: string; match: RegExpMatchArray } | null = null; + + for (const [type, pattern] of Object.entries(MARKDOWN_PATTERNS)) { + const match = line.slice(offset).match(pattern.regex); + if (!match) continue; + + if ( + !bestMatch || + (match.index ?? Infinity) < (bestMatch.match.index ?? Infinity) + ) { + bestMatch = { type, match }; + } + } + + return bestMatch; +} export const parseInlineElements = ( line: string, index: number, currentOffset: number -) => { - const boldRegex = /\*\*(.*?)\*\*/ - const italicRegex = /_(.*?)_/ - const underlineRegex = /\-\-(.*?)\-\-/ - const deleteRegex = /~~(.*?)~~/ - const inlineCodeRegex = /`(.*?)`/ - const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/ - const imageRegex = /!\[(.*?)\]\((.*?)\)/g - const htmlRegex = /<([a-zA-Z0-9]+)([^>]*)>(.*?)<\/\1>/g - let offset = 0 - let children = [] - let lastIndex = 0 +): Tokens[] => { + let offset = 0; + let children: Tokens[] = []; + let lastIndex = 0; + + const context: ProcessContext = { + line, + index, + currentOffset, + offset, + parseInlineElements, + }; while (offset < line.length) { - const boldMatch = line.slice(offset).match(boldRegex) - const italicMatch = line.slice(offset).match(italicRegex) - const underlineMatch = line.slice(offset).match(underlineRegex) - const deleteMatch = line.slice(offset).match(deleteRegex) - const inlineCodeMatch = line.slice(offset).match(inlineCodeRegex) - const imageMatch = line.slice(offset).match(imageRegex) - const linkMatch = line.slice(offset).match(linkRegex) - const htmlMatch = line.slice(offset).match(htmlRegex) + const nextMatch = findNextMatch(line, offset); - let match: RegExpMatchArray | null = null - let type = '' - let regex = null + if (!nextMatch) break; - if ( - boldMatch && - (!match || - (boldMatch.index ?? Infinity) < - ((match as RegExpMatchArray)?.index ?? Infinity)) - ) { - match = boldMatch - type = 'bold' - regex = boldRegex - } - if ( - italicMatch && - (!match || (italicMatch.index ?? Infinity) < (match?.index ?? Infinity)) - ) { - match = italicMatch - type = 'italic' - regex = italicRegex - } - if ( - underlineMatch && - (!match || - (underlineMatch.index ?? Infinity) < (match?.index ?? Infinity)) - ) { - match = underlineMatch - type = 'underline' - regex = underlineRegex - } - if ( - deleteMatch && - (!match || (deleteMatch.index ?? Infinity) < (match?.index ?? Infinity)) - ) { - match = deleteMatch - type = 'delete' - regex = deleteRegex - } - if ( - inlineCodeMatch && - (!match || - (inlineCodeMatch.index ?? Infinity) < (match?.index ?? Infinity)) - ) { - match = inlineCodeMatch - type = 'inlineCode' - regex = inlineCodeRegex - } - if (htmlMatch && (!match || (htmlMatch.index ?? Infinity) < (match?.index ?? Infinity))) { - match = htmlMatch - type = 'html' - regex = htmlRegex - } + const { type, match } = nextMatch; - if (imageMatch) { - for (let i = 0; i < imageMatch.length; i++) { - const fullMatch = imageMatch[i] - const [alt, url] = fullMatch - .match(/^!\[(.*?)\]\((.*?)\)$/) - ?.slice(1) || ['', ''] - const imageNode = { - type: 'image', - title: null, - url: url, - alt: alt, - position: { - start: { - line: index + 1, - column: offset + 1, - offset: currentOffset + offset - }, - end: { - line: index + 1, - column: offset + fullMatch.length + 1, - offset: currentOffset + offset + fullMatch.length - } - } - } - children.push(imageNode) - offset += fullMatch.length - lastIndex = offset - } - } else { - if ( - linkMatch && - (!match || (linkMatch.index ?? Infinity) < (match?.index ?? Infinity)) - ) { - match = linkMatch - type = 'link' - regex = linkRegex - } + // 处理匹配前的文本 + if (match.index && match.index > 0) { + children.push({ + type: "text", + value: line.slice(offset, offset + match.index), + position: { + start: { + line: index + 1, + column: offset + 1, + offset: currentOffset + offset, + }, + end: { + line: index + 1, + column: offset + match.index + 1, + offset: currentOffset + offset + match.index, + }, + }, + }); } - if (match) { - // 如果是空的行内代码块,直接作为普通文本处理 - if (type === 'inlineCode' && !match[1].trim()) { - children.push({ - type: 'text', - value: match[0], - position: { - start: { - line: index + 1, - column: offset + (match.index ?? 0) + 1, - offset: currentOffset + offset + (match.index ?? 0) - }, - end: { - line: index + 1, - column: offset + (match.index ?? 0) + match[0].length + 1, - offset: currentOffset + offset + (match.index ?? 0) + match[0].length - } - } - }) - offset += (match.index ?? 0) + match[0].length - lastIndex = offset - continue - } + // 处理匹配的标记 + context.offset = offset; + children.push(MARKDOWN_PATTERNS[type].process(match, context)); - if (match.index !== undefined && match.index > 0) { - children.push({ - type: 'text', - value: line.slice(offset, offset + (match.index ?? 0)), - position: { - start: { - line: index + 1, - column: offset + 1, - offset: currentOffset + offset - }, - end: { - line: index + 1, - column: offset + (match.index ?? 0) + 1, - offset: currentOffset + offset + (match.index ?? 0) - } - } - }) - } - if (type === 'link') { - children.push({ - type: type, - title: null, - url: match[2], - children: [ - { - type: 'text', - value: match[1], - position: { - start: { - line: index + 1, - column: offset + (match.index ?? 0) + 1, - offset: currentOffset + offset + (match.index ?? 0) - }, - end: { - line: index + 1, - column: offset + (match.index ?? 0) + match[1].length + 1, - offset: - currentOffset + - offset + - (match.index ?? 0) + - match[1].length - } - } - } - ], - position: { - start: { - line: index + 1, - column: offset + 1, - offset: currentOffset + offset - }, - end: { - line: index + 1, - column: offset + (match.index ?? 0) + match[0].length + 1, - offset: - currentOffset + offset + (match.index ?? 0) + match[0].length - } - } - }) - } else if (type === 'html') { // 添加HTML标签处理逻辑 - children.push({ - type: type, - value: match[0], - position: { - start: { - line: index + 1, - column: offset + 1, - offset: currentOffset + offset - }, - end: { - line: index + 1, - column: offset + match[0].length + 1, - offset: currentOffset + offset + match[0].length - } - } - }) - } else { - // 递归解析内部内容 - const innerContent = match[1] - const innerOffset = currentOffset + offset + (match.index ?? 0) + (type === 'bold' || type === 'delete' ? 2 : 1) - const innerChildren: any = parseInlineElements(innerContent, index, innerOffset) - - children.push({ - type: type, - children: innerChildren, - position: { - start: { line: index + 1, column: offset + (match.index ?? 0) + 1, offset: currentOffset + offset + (match.index ?? 0) }, - end: { line: index + 1, column: offset + (match.index ?? 0) + match[0].length + 1, offset: currentOffset + offset + (match.index ?? 0) + match[0].length } - } - }) - } - - offset += (match.index ?? 0) + match[0].length - lastIndex = offset - } else { - break - } + offset += (match.index ?? 0) + match[0].length; + lastIndex = offset; } + // 处理剩余文本 if (lastIndex < line.length) { children.push({ - type: 'text', + type: "text", value: line.slice(lastIndex), position: { start: { line: index + 1, column: lastIndex + 1, - offset: currentOffset + lastIndex + offset: currentOffset + lastIndex, }, end: { line: index + 1, column: line.length + 1, - offset: currentOffset + line.length - } - } - }) + offset: currentOffset + line.length, + }, + }, + }); } - return children -} + + return children; +}; -- Gitee From 1b15e4451898bfec45f6443b7ff30b4e0f9139bc Mon Sep 17 00:00:00 2001 From: e1chan <876394202@qq.com> Date: Fri, 24 Jan 2025 18:51:13 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(mini-markdown-ast-parser):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=87=8D=E6=9E=84=E5=90=8EinlineCode=E6=A0=91?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=BC=82=E5=B8=B8=E7=9A=84=E9=97=AE=E9=A2=98?= =?UTF-8?q?,=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E4=B8=BA=E9=93=BE=E6=8E=A5=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/parse/compose/inline/index.ts | 48 ++++++------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/packages/mini-markdown-ast-parser/src/core/parse/compose/inline/index.ts b/packages/mini-markdown-ast-parser/src/core/parse/compose/inline/index.ts index fcc7a74..852c1bc 100644 --- a/packages/mini-markdown-ast-parser/src/core/parse/compose/inline/index.ts +++ b/packages/mini-markdown-ast-parser/src/core/parse/compose/inline/index.ts @@ -11,34 +11,26 @@ interface ProcessContext { index: number; offset: number; currentOffset: number; - parseInlineElements: ( - line: string, - index: number, - currentOffset: number - ) => Tokens[]; + parseInlineElements: (line: string, index: number, currentOffset: number) => Tokens[]; } // 定义所有 Markdown 模式 const MARKDOWN_PATTERNS: Record = { bold: { regex: /\*\*(?.*?)\*\*/, - process: (match, context) => - createStandardToken("bold" as TokenTypeVal, match, context), + process: (match, context) => createStandardToken("bold" as TokenTypeVal, match, context), }, italic: { regex: /\_(?.*?)\_/, - process: (match, context) => - createStandardToken("italic" as TokenTypeVal, match, context), + process: (match, context) => createStandardToken("italic" as TokenTypeVal, match, context), }, underline: { regex: /\-{2}(?.*?)\-{2}/, - process: (match, context) => - createStandardToken("underline" as TokenTypeVal, match, context), + process: (match, context) => createStandardToken("underline" as TokenTypeVal, match, context), }, delete: { regex: /\~{2}(?.*?)\~{2}/, - process: (match, context) => - createStandardToken("delete" as TokenTypeVal, match, context), + process: (match, context) => createStandardToken("delete" as TokenTypeVal, match, context), }, inlineCode: { regex: /`(?.*?)`/, @@ -48,13 +40,13 @@ const MARKDOWN_PATTERNS: Record = { } return { type: "inlineCode", - value: match.groups?.content || match[1], + children: [createTextToken(match.groups?.content || "", match, context)], position: createPosition(match, context), }; }, }, image: { - regex: /!\[(?.*?)\]\((?.*?)\)/g, + regex: /!\[(?.*?)\]\((?.*?)\)/, process: (match, context) => ({ type: "image", title: null, @@ -64,7 +56,7 @@ const MARKDOWN_PATTERNS: Record = { }), }, link: { - regex: /\[(?[^\]]+)\]\((?[^)]+)\)/, + regex: /(?[^\]]+)\]\((?[^)]+)\)/, process: (match, context) => ({ type: "link", title: null, @@ -85,8 +77,7 @@ const MARKDOWN_PATTERNS: Record = { // 创建位置信息 function createPosition(match: RegExpMatchArray, context: ProcessContext) { - const startOffset = - context.currentOffset + context.offset + (match.index ?? 0); + const startOffset = context.currentOffset + context.offset + (match.index ?? 0); const endOffset = startOffset + match[0].length; return { @@ -104,11 +95,7 @@ function createPosition(match: RegExpMatchArray, context: ProcessContext) { } // 创建文本节点 -function createTextToken( - value: string, - match: RegExpMatchArray, - context: ProcessContext -): Tokens { +function createTextToken(value: string, match: RegExpMatchArray, context: ProcessContext): Tokens { return { type: "text", value, @@ -120,7 +107,7 @@ function createTextToken( function createStandardToken( type: TokenTypeVal, match: RegExpMatchArray, - context: ProcessContext + context: ProcessContext, ): Tokens { const innerContent = match.groups?.content || match[1]; const innerOffset = @@ -131,11 +118,7 @@ function createStandardToken( return { type, - children: context.parseInlineElements( - innerContent, - context.index, - innerOffset - ), + children: context.parseInlineElements(innerContent, context.index, innerOffset), position: createPosition(match, context), }; } @@ -148,10 +131,7 @@ function findNextMatch(line: string, offset: number) { const match = line.slice(offset).match(pattern.regex); if (!match) continue; - if ( - !bestMatch || - (match.index ?? Infinity) < (bestMatch.match.index ?? Infinity) - ) { + if (!bestMatch || (match.index ?? Infinity) < (bestMatch.match.index ?? Infinity)) { bestMatch = { type, match }; } } @@ -162,7 +142,7 @@ function findNextMatch(line: string, offset: number) { export const parseInlineElements = ( line: string, index: number, - currentOffset: number + currentOffset: number, ): Tokens[] => { let offset = 0; let children: Tokens[] = []; -- Gitee From f3f9919133573ecc5fb953bf5f7ac333c8a0e988 Mon Sep 17 00:00:00 2001 From: tabzzz Date: Fri, 24 Jan 2025 21:15:47 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat(mini-markdown-editor):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=BC=96=E8=BE=91=E5=99=A8=E5=86=85=E5=AE=B9=E7=9A=84?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mini-markdown-editor/src/common/index.ts | 2 + .../src/components/Editor/index.tsx | 18 ++- .../src/components/Toolbar/CopyCodeButton.tsx | 42 +++++++ .../src/components/Toolbar/ToolbarItem.tsx | 6 +- .../src/components/Toolbar/index.tsx | 36 ++++-- .../mini-markdown-editor/src/store/editor.ts | 8 +- .../mini-markdown-editor/src/utils/storage.ts | 117 ++++++++++++++++++ 7 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 packages/mini-markdown-editor/src/common/index.ts create mode 100644 packages/mini-markdown-editor/src/components/Toolbar/CopyCodeButton.tsx create mode 100644 packages/mini-markdown-editor/src/utils/storage.ts diff --git a/packages/mini-markdown-editor/src/common/index.ts b/packages/mini-markdown-editor/src/common/index.ts new file mode 100644 index 0000000..1a15c7f --- /dev/null +++ b/packages/mini-markdown-editor/src/common/index.ts @@ -0,0 +1,2 @@ +// 编辑器内容KEY +export const EDITOR_CONTENT_KEY = "markdown-editor-content"; diff --git a/packages/mini-markdown-editor/src/components/Editor/index.tsx b/packages/mini-markdown-editor/src/components/Editor/index.tsx index b789a64..7091004 100644 --- a/packages/mini-markdown-editor/src/components/Editor/index.tsx +++ b/packages/mini-markdown-editor/src/components/Editor/index.tsx @@ -1,4 +1,4 @@ -import { FC, useRef } from "react"; +import { FC, useEffect, useRef } from "react"; import styled from "styled-components"; import CodeMirror, { type EditorView, ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; @@ -6,6 +6,8 @@ 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"; +import { safeLocalStorage } from "@/utils/storage"; +import { EDITOR_CONTENT_KEY } from "@/common"; const ScrollWrapper = styled.div` width: 100%; @@ -40,11 +42,23 @@ const Editor: FC = () => { setContent, scrollWrapper, setScrollWrapper, + editorView, setEditorView, previewView, setEditorRef, } = useEditorContentStore(); const editorRef = useRef(null); + const localStorage = safeLocalStorage(); + + // 处理重加载后的光标位置 + useEffect(() => { + if (editorView && content) { + // 将光标移动到文档末尾 + editorView.dispatch({ + selection: { anchor: content.length, head: content.length }, + }); + } + }, [editorView]); // 编辑器挂载完成后将编辑器示例存储起来 const handleCreate = (view: EditorView) => { @@ -54,6 +68,8 @@ const Editor: FC = () => { const handleChange = (val: string) => { setContent(val); + // 本地同步存储 + localStorage.setItem(EDITOR_CONTENT_KEY, val); }; const eventExt = events.scroll({ diff --git a/packages/mini-markdown-editor/src/components/Toolbar/CopyCodeButton.tsx b/packages/mini-markdown-editor/src/components/Toolbar/CopyCodeButton.tsx new file mode 100644 index 0000000..2e7ccf7 --- /dev/null +++ b/packages/mini-markdown-editor/src/components/Toolbar/CopyCodeButton.tsx @@ -0,0 +1,42 @@ +import { Button, message } from "antd"; +import { FC } from "react"; +import code from "@/mock/preview.md?raw"; + +interface CopyCodeButtonProps { + textToCopy?: string; + successMessage?: string; + errorMessage?: string; +} + +export const CopyCodeButton: FC = ({ + textToCopy = code, + successMessage = "已复制!", + errorMessage = "复制失败", +}) => { + // 仅在开发环境下展示 + if (process.env.NODE_ENV === "production") { + return null; + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(textToCopy); + message.success({ + content: successMessage, + duration: 1.5, + }); + } catch (err) { + console.error("复制失败:", err); + message.error({ + content: errorMessage, + duration: 2, + }); + } + }; + + return ( + + ); +}; diff --git a/packages/mini-markdown-editor/src/components/Toolbar/ToolbarItem.tsx b/packages/mini-markdown-editor/src/components/Toolbar/ToolbarItem.tsx index c78818f..08d065c 100644 --- a/packages/mini-markdown-editor/src/components/Toolbar/ToolbarItem.tsx +++ b/packages/mini-markdown-editor/src/components/Toolbar/ToolbarItem.tsx @@ -20,8 +20,10 @@ const ToolbarItemWrapper = styled.div` background-color: #e6e6e6; } img { - width: 100%; - height: 100%; + width: 16px; + height: 16px; + display: block; + flex-shrink: 0; } `; diff --git a/packages/mini-markdown-editor/src/components/Toolbar/index.tsx b/packages/mini-markdown-editor/src/components/Toolbar/index.tsx index 3627327..5ca1e10 100644 --- a/packages/mini-markdown-editor/src/components/Toolbar/index.tsx +++ b/packages/mini-markdown-editor/src/components/Toolbar/index.tsx @@ -2,6 +2,7 @@ import { FC } from "react"; import styled from "styled-components"; import { useToolbar } from "@/hooks/use-toolbar"; import { ToolbarItem } from "./ToolbarItem"; +import { CopyCodeButton } from "./CopyCodeButton"; const ToolbarContent = styled.div` width: 100%; @@ -10,6 +11,17 @@ const ToolbarContent = styled.div` padding: 4px; display: flex; align-items: center; + justify-content: space-between; +`; + +const ToolbarLeft = styled.div` + display: flex; + align-items: center; +`; + +const ToolbarRight = styled.div` + display: flex; + align-items: center; `; const Divider = styled.div` @@ -27,13 +39,23 @@ const Toolbar: FC = () => { return ( - {toolbars.map((item, index) => - item.type === "line" ? ( - - ) : ( - - ), - )} + + {toolbars.map((item, index) => + item.type === "line" ? ( + + ) : ( + + ), + )} + + + + ); }; diff --git a/packages/mini-markdown-editor/src/store/editor.ts b/packages/mini-markdown-editor/src/store/editor.ts index 1584cda..53d93bd 100644 --- a/packages/mini-markdown-editor/src/store/editor.ts +++ b/packages/mini-markdown-editor/src/store/editor.ts @@ -1,7 +1,8 @@ import { create } from "zustand"; -import code from "@/mock/preview.md?raw"; import type { EditorView } from "@codemirror/view"; import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { safeLocalStorage } from "@/utils/storage"; +import { EDITOR_CONTENT_KEY } from "@/common"; interface EditorContentStoreType { content: string; @@ -19,9 +20,12 @@ interface EditorContentStoreType { setPreviewView: (view: HTMLElement | null) => void; } +const localStorage = safeLocalStorage(); + // 编辑器内容状态 const useEditorContentStore = create((set, get) => ({ - content: code, + // content: code, + content: localStorage.getItem(EDITOR_CONTENT_KEY) || "", setContent: (content: string) => set({ content }), scrollWrapper: "", setScrollWrapper: (scrollWrapper: string) => set({ scrollWrapper }), diff --git a/packages/mini-markdown-editor/src/utils/storage.ts b/packages/mini-markdown-editor/src/utils/storage.ts new file mode 100644 index 0000000..3a485b3 --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/storage.ts @@ -0,0 +1,117 @@ +/** + * localStorage 包装器 + */ +export interface SafeStorage { + getItem: (key: string) => T | null; + setItem: (key: string, value: T) => void; + removeItem: (key: string) => void; + clear: () => void; +} + +export function safeLocalStorage(): SafeStorage { + //* 使用闭包存储storage状态 + const getStorage = (): Storage | null => { + try { + // 检查localStorage是否可用 + if (typeof window !== "undefined" && window.localStorage) { + const testKey = "__storage_test__"; + window.localStorage.setItem(testKey, testKey); + window.localStorage.removeItem(testKey); + return window.localStorage; + } + } catch (e) { + console.error("localStorage is not available:", e); + } + return null; + }; + + // 缓存storage实例 + const storage = getStorage(); + + // 统一的错误处理 + const handleStorageError = (operation: string, key?: string) => { + const message = key + ? `Attempted to ${operation} "${key}" ${operation === "get" ? "from" : "in"} localStorage, but localStorage is not available.` + : `Attempted to ${operation} localStorage, but localStorage is not available.`; + console.warn(message); + }; + + // 序列化和反序列化数据 + const serialize = (value: T): string => { + try { + return JSON.stringify(value); + } catch (e) { + console.error("Failed to serialize value:", e); + return String(value); + } + }; + const deserialize = (value: string): T | null => { + try { + return JSON.parse(value) as T; + } catch (e) { + console.error("Failed to deserialize value:", e); + return null; + } + }; + + return { + // 提取数据 + getItem(key: string): T | null { + if (!storage) { + handleStorageError("get", key); + return null; + } + + try { + const value = storage.getItem(key); + return value ? deserialize(value) : null; + } catch (e) { + console.error(`Error getting item "${key}" from localStorage:`, e); + return null; + } + }, + + // 存储数据 + setItem(key: string, value: T): void { + if (!storage) { + handleStorageError("set", key); + return; + } + + try { + storage.setItem(key, serialize(value)); + } catch (e) { + console.error(`Error setting item "${key}" in localStorage:`, e); + // TODO: 处理超额存储 + } + }, + + // 删除指定数据项 + removeItem(key: string): void { + if (!storage) { + handleStorageError("remove", key); + return; + } + + try { + storage.removeItem(key); + } catch (e) { + console.error(`Error removing item "${key}" from localStorage:`, e); + } + }, + + // 清空所有数据项 + clear(): void { + if (!storage) { + handleStorageError("clear"); + return; + } + + try { + storage.clear(); + } catch (e) { + console.error("Error clearing localStorage:", e); + } + }, + }; +} -- Gitee