From bd5e3855ead8689fc49c1c9cf5b3c5ff8d4e8e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?wifi=E6=AD=AAf?= <1402772884@qq.com> Date: Wed, 29 Jan 2025 13:03:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(mini-markdown-editor):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=BE=A7=E8=BE=B9=E6=A0=8F=E7=9B=AE=E5=BD=95=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs/index.md | 73 ++++++++++++--- packages/mini-markdown-editor/package.json | 2 +- .../src/EditorWrapper.tsx | 10 +- .../src/assets/styles/preview.css | 1 + .../src/common/hotkeys.ts | 2 +- .../src/components/Editor/index.tsx | 1 + .../src/components/Sidebar/Contents.tsx | 93 ++++++++++++++++++- .../src/config/toolbar/event.ts | 8 -- .../src/utils/format-contents.ts | 58 ++++++++++++ 9 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 packages/mini-markdown-editor/src/utils/format-contents.ts diff --git a/docs/docs/index.md b/docs/docs/index.md index 7aa4edb..4e8275d 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,69 +1,116 @@ 让我来对比一下 CSS-in-JS(以 styled-components 为例)和 CSS Modules 这两种方案的优缺点: ### CSS-in-JS 优点: + 1. 动态样式能力强 - + - 可以基于 props 动态计算样式 - 支持主题系统,通过 ThemeProvider 可以方便地实现主题切换 - 可以直接在组件中使用 JavaScript 变量和函数 + 2. 组件化程度高 - + - 样式与组件紧密绑定,提高了代码的内聚性 - 支持样式组件的继承和组合 - 样式作用域完全隔离 + 3. 开发体验好 - + - 不需要在组件和样式文件之间切换 - 支持模板字符串语法,写法接近原生 CSS - 支持自动添加浏览器前缀 + ### CSS-in-JS 缺点: + 1. 运行时开销 - + - 需要在运行时生成和注入样式 - 首次渲染性能较差 - bundle 体积会增大 + 2. 调试相对困难 - + - 生成的类名是动态的,不易于调试 - DevTools 中查看样式不如传统 CSS 直观 + ### CSS Modules 优点: + 1. 性能更好 - + - 在构建时生成样式,没有运行时开销 - bundle 体积更小 - 首次渲染更快 + 2. 更好的工具支持 - + - 完整的 CSS 语法支持 - 更好的 IDE 提示和自动补全 - 更容易与现有的 CSS 工具链集成(如 PostCSS) + 3. 调试友好 - + - 生成的类名可预测 - 在 DevTools 中更容易定位样式问题 - 支持 source map + ### CSS Modules 缺点: + 1. 动态样式能力有限 - + - 不能基于 props 直接生成样式 - 需要通过类名组合来实现动态样式 - 主题系统实现相对复杂 + 2. 开发体验略差 - + - 需要在组件和样式文件之间切换 - 类名管理需要额外注意 - 样式复用相对困难 + ### 选择建议: + 1. 选择 CSS-in-JS 的场景: - + - 需要高度动态的样式系统 - 组件库开发 - 需要主题系统的项目 - 小型项目或对性能要求不是特别高的场景 + 2. 选择 CSS Modules 的场景: - - 对性能要求较高的大型项目 - 样式相对静态的项目 - 需要与现有 CSS 工具链集成的项目 - 团队更熟悉传统 CSS 开发模式 -在实际项目中,这两种方案也可以混合使用,根据具体场景选择最适合的方案。例如,可以使用 CSS Modules 处理大部分静态样式,而在需要动态样式的特定组件中使用 CSS-in-JS。 \ No newline at end of file + 在实际项目中,这两种方案也可以混合使用,根据具体场景选择最适合的方案。例如,可以使用 CSS Modules 处理大部分静态样式,而在需要动态样式的特定组件中使用 CSS-in-JS。 + +## 目录获取渲染区dom不同步问题 + +使用 MutationObserver 的原因是为了解决 React 状态更新和 DOM 渲染的时序问题。让我解释一下: + +1. 虽然 preview 内容更新会被设置到仓库中,但是 DOM 的实际渲染可能会有延迟。这是因为: + + - React 的状态更新是异步的 + - DOM 的实际渲染会在 React 完成虚拟 DOM 的计算后才进行 + - 特别是在处理 Markdown 渲染这种复杂操作时,DOM 的更新可能会有一定延迟 + +2. 如果不使用 MutationObserver ,可能会出现以下问题: + + ```typescript + useEffect(() => { + if (!previewView) return; + + // 这时候虽然 content 更新了,但 DOM 可能还没有渲染完成 + const elements = previewView.querySelectorAll("h1, h2, h3, h4, h5, h6"); + setTitles(formatContents(elements)); // 可能会获取不到最新的标题 + }, [content, previewView]); + ``` + + ``` + + ``` + +3. 使用 MutationObserver 的好处: + - 它能够直接监听 DOM 的变化 + - 只有在 DOM 真正更新后才会触发回调 + - 确保我们获取到的是最新的 DOM 结构 + 所以,虽然内容会更新到仓库,但使用 MutationObserver 可以确保我们在正确的时机(DOM 真正更新后)获取标题列表,避免出现标题列表不同步的问题。 diff --git a/packages/mini-markdown-editor/package.json b/packages/mini-markdown-editor/package.json index 1538b5f..824cbef 100644 --- a/packages/mini-markdown-editor/package.json +++ b/packages/mini-markdown-editor/package.json @@ -8,7 +8,7 @@ "dev": "pnpm build:ast && vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview" }, "dependencies": { "@codemirror/lang-markdown": "^6.3.2", diff --git a/packages/mini-markdown-editor/src/EditorWrapper.tsx b/packages/mini-markdown-editor/src/EditorWrapper.tsx index d009c1c..497ef07 100644 --- a/packages/mini-markdown-editor/src/EditorWrapper.tsx +++ b/packages/mini-markdown-editor/src/EditorWrapper.tsx @@ -72,15 +72,15 @@ const Divider = styled.div` // 布局配置映射 const LayoutConfig = { // 只写模式 - WRITE_ONLY: { cols: [18], components: ["editor"] }, + WRITE_ONLY: { cols: [18, 0, 0], components: ["editor", "preview", "sidebar"] }, // 仅预览模式 - READ_ONLY: { cols: [18], components: ["preview"] }, + READ_ONLY: { cols: [0, 18, 0], components: ["editor", "preview", "sidebar"] }, // 读写模式 - READ_WRITE: { cols: [12, 12], components: ["editor", "preview"] }, + READ_WRITE: { cols: [12, 12, 0], components: ["editor", "preview", "sidebar"] }, // 只写+侧边栏 - WRITE_ONLY_SIDEBAR: { cols: [18, 6], components: ["editor", "sidebar"] }, + WRITE_ONLY_SIDEBAR: { cols: [18, 0, 6], components: ["editor", "preview", "sidebar"] }, // 仅预览+侧边栏 - READ_ONLY_SIDEBAR: { cols: [18, 6], components: ["preview", "sidebar"] }, + READ_ONLY_SIDEBAR: { cols: [0, 18, 6], components: ["editor", "preview", "sidebar"] }, // 读写+侧边栏 READ_WRITE_SIDEBAR: { cols: [9, 9, 6], components: ["editor", "preview", "sidebar"] }, }; diff --git a/packages/mini-markdown-editor/src/assets/styles/preview.css b/packages/mini-markdown-editor/src/assets/styles/preview.css index dd022b9..6080978 100644 --- a/packages/mini-markdown-editor/src/assets/styles/preview.css +++ b/packages/mini-markdown-editor/src/assets/styles/preview.css @@ -56,6 +56,7 @@ } .mini-md-image { + width: 100%; max-width: 100%; border: 1px solid #e6e6e6; border-radius: 3px; diff --git a/packages/mini-markdown-editor/src/common/hotkeys.ts b/packages/mini-markdown-editor/src/common/hotkeys.ts index 53735a8..f97f4da 100644 --- a/packages/mini-markdown-editor/src/common/hotkeys.ts +++ b/packages/mini-markdown-editor/src/common/hotkeys.ts @@ -12,7 +12,7 @@ export class Hotkey { SIXTH: new Hotkey("mod+6", "heaing-6"), } as const; static readonly BOLD = new Hotkey("mod+b", "bold"); // **text** - static readonly ITALIC = new Hotkey("mod+i", "italic"); // *text* + static readonly ITALIC = new Hotkey("mod+i", "italic"); // _text_ static readonly UNDERLINE = new Hotkey("mod+u", "underline"); // --text-- static readonly DELETE = new Hotkey("mod+shift+x", "delete"); // ~~text~~ static readonly BLOCKQUOTE = new Hotkey("mod+shift+9", "blockquote"); // > text diff --git a/packages/mini-markdown-editor/src/components/Editor/index.tsx b/packages/mini-markdown-editor/src/components/Editor/index.tsx index dd03154..4094e24 100644 --- a/packages/mini-markdown-editor/src/components/Editor/index.tsx +++ b/packages/mini-markdown-editor/src/components/Editor/index.tsx @@ -59,6 +59,7 @@ const Editor: FC = () => { }, [editorViewRef], ); + // 监听快捷键 useEditorShortcuts(); // 处理重加载后的光标位置 diff --git a/packages/mini-markdown-editor/src/components/Sidebar/Contents.tsx b/packages/mini-markdown-editor/src/components/Sidebar/Contents.tsx index 571b251..cc68ed1 100644 --- a/packages/mini-markdown-editor/src/components/Sidebar/Contents.tsx +++ b/packages/mini-markdown-editor/src/components/Sidebar/Contents.tsx @@ -1,7 +1,96 @@ -import { FC } from "react"; +import { formatContents, Title } from "@/utils/format-contents"; +import { Anchor } from "antd"; +import { FC, useEffect, useMemo, useState } from "react"; +import { useEditorContentStore } from "@/store/editor"; const Contents: FC = () => { - return <>Contents; + const previewView = useEditorContentStore((state) => state.previewView); + const [titles, setTitles] = useState([]); + const preview = document.querySelector(".markdown-editor-preview") as HTMLElement | null; + const getRootElement = () => { + return preview?.querySelectorAll("h1, h2, h3, h4, h5, h6") as NodeListOf; + }; + + const rootElement = useMemo(getRootElement, [preview]); + + // 获取 preview 容器的标题节点 + const addAnchor = () => { + if (!rootElement) return []; + rootElement.forEach((node) => { + const line = node.getAttribute("data-line"); + if (!line) return; + node.setAttribute("id", line); + }); + return formatContents(rootElement); + }; + + useEffect(() => { + if (!preview) return; + + // 初始化时立即执行一次 + if (rootElement.length > 0) { + setTitles(addAnchor()); + } + + const observer = new MutationObserver(() => { + // 再获取一次元素,防止更新不及时 + const elements = getRootElement(); + if (elements && elements.length > 0) { + requestAnimationFrame(() => { + setTitles(formatContents(elements)); + }); + } + }); + + observer.observe(preview, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + }); + + return () => { + observer.disconnect(); + }; + }, [preview, rootElement]); + + // 自定义高亮锚点(默认选中第一个) + const getCurrentAnchor = (activeLink: string): string => { + if (!activeLink && titles.length > 0) { + activeLink = titles[0].href; + } + return activeLink; + }; + + const handleClickAnchor = ( + e: React.MouseEvent, + link: { + title: React.ReactNode; + href: string; + }, + ) => { + e.preventDefault(); + if (link.href && previewView) { + const targetElement = previewView.querySelector(`[data-line="${link.href}"]`); + if (targetElement) { + targetElement.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + } + } + }; + + return titles.length > 0 && preview ? ( + preview} + onClick={handleClickAnchor} + /> + ) : null; }; export default Contents; diff --git a/packages/mini-markdown-editor/src/config/toolbar/event.ts b/packages/mini-markdown-editor/src/config/toolbar/event.ts index a8e2f0e..9ebf1e0 100644 --- a/packages/mini-markdown-editor/src/config/toolbar/event.ts +++ b/packages/mini-markdown-editor/src/config/toolbar/event.ts @@ -22,12 +22,4 @@ export const InsertImageEvent = (url: string, alt: string) => { // 重做 -// 只写 - -// 仅预览 - -// 打开目录 - -// 帮助 - // 导出为PDF diff --git a/packages/mini-markdown-editor/src/utils/format-contents.ts b/packages/mini-markdown-editor/src/utils/format-contents.ts new file mode 100644 index 0000000..715ae7e --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/format-contents.ts @@ -0,0 +1,58 @@ +export type Title = { + key: string; + href: string; + title: string; + children: Title[]; + nodeName: string; +}; + +// 格式化 preview 区标题的dom节点 +export const formatContents = (rootElement: NodeListOf) => { + // 将NodeList转换为数组,提取需要的属性 + const rootElementArr = Array.from(rootElement).map((item) => { + const dataLine = item.getAttribute("data-line"); + const title = { + key: dataLine, + href: `#${dataLine}`, + title: item.innerText, + children: [], + nodeName: item.nodeName, + }; + return title; + }) as Title[]; + + let result = rootElementArr; + let preLength = 0; + let newLength = result.length; + while (preLength !== newLength) { + preLength = result.length; + const list: Title[] = []; + let childList: Title[] = []; + for (let index = result.length - 1; index >= 0; index--) { + if ( + result[index - 1] && + result[index - 1].nodeName.charAt(1) === result[index].nodeName.charAt(1) + ) { + childList.unshift(result[index]); + } else if ( + result[index - 1] && + result[index - 1].nodeName.charAt(1) < result[index].nodeName.charAt(1) + ) { + childList.unshift(result[index]); + result[index - 1].children = [...(result[index - 1].children as []), ...childList]; + childList = []; + } else { + childList.unshift(result[index]); + if (childList.length > 0) { + list.unshift(...childList); + } else { + list.unshift(result[index]); + } + childList = []; + } + } + result = list; + newLength = result.length; + } + return result; +}; -- Gitee