From cdfc5654eb7fc2fca702997b319fc48936ebb771 Mon Sep 17 00:00:00 2001 From: tabzzz Date: Thu, 6 Feb 2025 16:27:19 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(mini-markdown-editor):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E7=BC=96=E8=BE=91=E5=99=A8=E5=85=A8=E5=B1=8F=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=9A=84=E5=BF=AB=E6=8D=B7=E9=94=AE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/extensions/codemirror/hotkeys.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mini-markdown-editor/src/extensions/codemirror/hotkeys.ts b/packages/mini-markdown-editor/src/extensions/codemirror/hotkeys.ts index 5b675df..99fce7a 100644 --- a/packages/mini-markdown-editor/src/extensions/codemirror/hotkeys.ts +++ b/packages/mini-markdown-editor/src/extensions/codemirror/hotkeys.ts @@ -3,6 +3,7 @@ import { keymap } from "@codemirror/view"; import { Hotkey } from "@/common/hotkeys"; import { InsertTextEvent } from "@/config/toolbar/event"; import { ToolbarType } from "@/types/toolbar"; +import { useToolbarStore } from "@/store/toolbar"; // 定义默认快捷键支持 const KEYMAP = { @@ -148,7 +149,10 @@ const KEYMAP = { [Hotkey.FULL_SCREEN.codeMirrorCommand]: { run: () => { Hotkey.FULL_SCREEN.handle?.(); - InsertTextEvent(Hotkey.FULL_SCREEN.description as ToolbarType); + const currentState = useToolbarStore.getState(); + useToolbarStore.setState({ + isFullScreen: !currentState.isFullScreen, + }); return true; }, preventDefault: true, @@ -156,7 +160,7 @@ const KEYMAP = { [Hotkey.SAVE.codeMirrorCommand]: { run: () => { Hotkey.SAVE.handle?.(); - InsertTextEvent(Hotkey.SAVE.description as ToolbarType); + // TODO: 添加保存事件 return true; }, preventDefault: true, -- Gitee From a6a9c2cfdeb8222fc58f762eb47451ebcf3d5567 Mon Sep 17 00:00:00 2001 From: tabzzz Date: Thu, 6 Feb 2025 19:10:28 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(mini-markdown-editor):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E4=BB=A3=E7=A0=81=E5=9D=97=E7=9A=84=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/assets/images/copy-done.svg | 1 + .../src/assets/images/copy.svg | 1 + .../src/components/Preview/CopyCodeButton.tsx | 87 +++++++++++++++++++ .../src/components/Preview/index.tsx | 51 ++++++++++- 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 packages/mini-markdown-editor/src/assets/images/copy-done.svg create mode 100644 packages/mini-markdown-editor/src/assets/images/copy.svg create mode 100644 packages/mini-markdown-editor/src/components/Preview/CopyCodeButton.tsx diff --git a/packages/mini-markdown-editor/src/assets/images/copy-done.svg b/packages/mini-markdown-editor/src/assets/images/copy-done.svg new file mode 100644 index 0000000..1693102 --- /dev/null +++ b/packages/mini-markdown-editor/src/assets/images/copy-done.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/mini-markdown-editor/src/assets/images/copy.svg b/packages/mini-markdown-editor/src/assets/images/copy.svg new file mode 100644 index 0000000..b0010fa --- /dev/null +++ b/packages/mini-markdown-editor/src/assets/images/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/mini-markdown-editor/src/components/Preview/CopyCodeButton.tsx b/packages/mini-markdown-editor/src/components/Preview/CopyCodeButton.tsx new file mode 100644 index 0000000..38f37df --- /dev/null +++ b/packages/mini-markdown-editor/src/components/Preview/CopyCodeButton.tsx @@ -0,0 +1,87 @@ +import { FC, useState } from "react"; +import styled from "styled-components"; +import CopyIcon from "@/assets/images/copy.svg?raw"; +import CopyDoneIcon from "@/assets/images/copy-done.svg?raw"; + +interface CopyButtonProps { + content: string; + className?: string; +} + +export const CopyButton: FC = ({ content, className = "" }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + // TODO: 错误处理 + console.log(err); + } + }; + + return ( + + + + ); +}; + +const IconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + + svg { + width: 100%; + height: 100%; + } +`; + +const StyledButton = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.05); + color: #ffffff; + } + + &:active { + transform: scale(0.95); + } + + &.copied { + color: #67c23a; + } + + .icon-wrapper { + opacity: 0.8; + transition: opacity 0.2s ease; + } + + &:hover .icon-wrapper { + opacity: 1; + } +`; + +export default CopyButton; diff --git a/packages/mini-markdown-editor/src/components/Preview/index.tsx b/packages/mini-markdown-editor/src/components/Preview/index.tsx index 448a3cf..60b3483 100644 --- a/packages/mini-markdown-editor/src/components/Preview/index.tsx +++ b/packages/mini-markdown-editor/src/components/Preview/index.tsx @@ -5,6 +5,8 @@ import { useEditorContentStore } from "@/store/editor"; import { handlePreviewScroll } from "@/utils/handle-scroll"; import { usePreviewTheme } from "@/hooks/use-preview-theme"; import { ConfigContext } from "../providers/config-provider"; +import { CopyButton } from "./CopyCodeButton"; +import ReactDOM from "react-dom"; const Preview: FC<{ content: string; isSyncScroll: boolean }> = ({ content, isSyncScroll }) => { const { scrollWrapper, setScrollWrapper, setPreviewView, editorView } = useEditorContentStore(); @@ -50,6 +52,44 @@ const Preview: FC<{ content: string; isSyncScroll: boolean }> = ({ content, isSy const { theme } = useContext(ConfigContext); usePreviewTheme(theme as "light" | "dark"); + // 处理代码块复制按钮 + useEffect(() => { + if (!previewRef.current) return; + + const addCopyButtons = () => { + const codeHeaders = previewRef.current?.querySelectorAll(".mini-md-code-right"); + codeHeaders?.forEach((header) => { + if (header.querySelector(".copy-code-button")) return; + + // 获取对应的代码内容 + const codeElement = header.closest(".mini-md-code-container")?.querySelector("code"); + if (!codeElement) return; + + const copyButtonContainer = document.createElement("div"); + copyButtonContainer.className = "copy-code-button-wrapper"; + + ReactDOM.render( + , + copyButtonContainer, + ); + + header.appendChild(copyButtonContainer); + }); + }; + + addCopyButtons(); + // 移除组件 + return () => { + const copyButtons = previewRef.current?.querySelectorAll(".copy-code-button-wrapper"); + copyButtons?.forEach((button) => { + if (button) { + ReactDOM.unmountComponentAtNode(button); + button.remove(); + } + }); + }; + }, [node]); + return ( Date: Thu, 6 Feb 2025 19:15:45 +0800 Subject: [PATCH 3/6] =?UTF-8?q?perf(mini-markdown-editor):=20=E4=B8=BA?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E4=BB=A3=E7=A0=81=E5=9D=97=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E9=98=B2=E6=8A=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Preview/CopyCodeButton.tsx | 30 ++++++++++++------- .../src/components/Preview/index.tsx | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/mini-markdown-editor/src/components/Preview/CopyCodeButton.tsx b/packages/mini-markdown-editor/src/components/Preview/CopyCodeButton.tsx index 38f37df..a3561b1 100644 --- a/packages/mini-markdown-editor/src/components/Preview/CopyCodeButton.tsx +++ b/packages/mini-markdown-editor/src/components/Preview/CopyCodeButton.tsx @@ -1,5 +1,6 @@ import { FC, useState } from "react"; import styled from "styled-components"; +import { useDebounceFn } from "ahooks"; import CopyIcon from "@/assets/images/copy.svg?raw"; import CopyDoneIcon from "@/assets/images/copy-done.svg?raw"; @@ -11,16 +12,25 @@ interface CopyButtonProps { export const CopyButton: FC = ({ content, className = "" }) => { const [copied, setCopied] = useState(false); - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - // TODO: 错误处理 - console.log(err); - } - }; + const { run: handleCopy } = useDebounceFn( + async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + } catch (err) { + // TODO: 错误处理 + console.log(err); + } + }, + { + wait: 300, + leading: true, + trailing: false, + }, + ); return ( = ({ content, isSy codeHeaders?.forEach((header) => { if (header.querySelector(".copy-code-button")) return; - // 获取对应的代码内容 const codeElement = header.closest(".mini-md-code-container")?.querySelector("code"); if (!codeElement) return; const copyButtonContainer = document.createElement("div"); copyButtonContainer.className = "copy-code-button-wrapper"; + // TODO: 尝试使用其它方法 ReactDOM.render( , copyButtonContainer, -- Gitee From a70693b773d9145bced8b012ccddf453e1d66e77 Mon Sep 17 00:00:00 2001 From: tabzzz Date: Thu, 6 Feb 2025 19:37:15 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(mini-markdown-editor):=20=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=E4=BA=86=E5=A4=8D=E5=88=B6=E6=8C=89=E9=92=AE=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E7=9A=84api=E5=BC=83=E7=94=A8=E7=9A=84error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Preview/index.tsx | 73 +++++++++++++------ 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/packages/mini-markdown-editor/src/components/Preview/index.tsx b/packages/mini-markdown-editor/src/components/Preview/index.tsx index 1ed299a..ebb0797 100644 --- a/packages/mini-markdown-editor/src/components/Preview/index.tsx +++ b/packages/mini-markdown-editor/src/components/Preview/index.tsx @@ -1,4 +1,5 @@ import React, { FC, useEffect, useRef, useCallback, useContext } from "react"; +import { createRoot, type Root } from "react-dom/client"; import { parseMarkdown, transformHtml } from "@mini-markdown/ast-parser"; import styled from "styled-components"; import { useEditorContentStore } from "@/store/editor"; @@ -6,12 +7,12 @@ import { handlePreviewScroll } from "@/utils/handle-scroll"; import { usePreviewTheme } from "@/hooks/use-preview-theme"; import { ConfigContext } from "../providers/config-provider"; import { CopyButton } from "./CopyCodeButton"; -import ReactDOM from "react-dom"; const Preview: FC<{ content: string; isSyncScroll: boolean }> = ({ content, isSyncScroll }) => { const { scrollWrapper, setScrollWrapper, setPreviewView, editorView } = useEditorContentStore(); - const previewRef = useRef(null); + //* 存储清理函数 + const cleanupRef = useRef<(() => void) | null>(null); // 更新预览视图 const updatePreviewView = useCallback( @@ -34,6 +35,7 @@ const Preview: FC<{ content: string; isSyncScroll: boolean }> = ({ content, isSy updatePreviewView(previewRef.current); }, [updatePreviewView, node]); + // 处理滚动事件 const handleScroll = useCallback( (e: React.UIEvent) => { if (scrollWrapper !== "preview") return; @@ -44,6 +46,7 @@ const Preview: FC<{ content: string; isSyncScroll: boolean }> = ({ content, isSy [scrollWrapper, editorView, isSyncScroll], ); + // 鼠标进入预览区域 const handleMouseEnter = useCallback(() => { setScrollWrapper("preview"); }, [setScrollWrapper]); @@ -55,38 +58,62 @@ const Preview: FC<{ content: string; isSyncScroll: boolean }> = ({ content, isSy // 处理代码块复制按钮 useEffect(() => { if (!previewRef.current) return; - + // 清理之前的按钮 + if (cleanupRef.current) { + cleanupRef.current(); + } + // 添加新的按钮 const addCopyButtons = () => { const codeHeaders = previewRef.current?.querySelectorAll(".mini-md-code-right"); + const currentRoots = new Map(); + codeHeaders?.forEach((header) => { - if (header.querySelector(".copy-code-button")) return; + try { + if (header.querySelector(".copy-code-button")) return; + + const codeElement = header.closest(".mini-md-code-container")?.querySelector("code"); + if (!codeElement) return; - const codeElement = header.closest(".mini-md-code-container")?.querySelector("code"); - if (!codeElement) return; + const copyButtonContainer = document.createElement("div"); + copyButtonContainer.className = "copy-code-button-wrapper"; - const copyButtonContainer = document.createElement("div"); - copyButtonContainer.className = "copy-code-button-wrapper"; + const root = createRoot(copyButtonContainer); + currentRoots.set(copyButtonContainer, root); - // TODO: 尝试使用其它方法 - ReactDOM.render( - , - copyButtonContainer, - ); + root.render(); - header.appendChild(copyButtonContainer); + header.appendChild(copyButtonContainer); + } catch (error) { + console.error("Copy Error:", error); + } }); + + // 保存清理函数 + cleanupRef.current = () => { + setTimeout(() => { + currentRoots.forEach((root, container) => { + try { + root.unmount(); + container.remove(); + } catch (error) { + console.error("Failed to cleanup copy button:", error); + } + }); + currentRoots.clear(); + }, 0); + }; }; - addCopyButtons(); - // 移除组件 + // 防止添加按钮时dom未渲染 + requestAnimationFrame(() => { + addCopyButtons(); + }); + return () => { - const copyButtons = previewRef.current?.querySelectorAll(".copy-code-button-wrapper"); - copyButtons?.forEach((button) => { - if (button) { - ReactDOM.unmountComponentAtNode(button); - button.remove(); - } - }); + // 组件卸载时执行清理,把清理函数执行 + if (cleanupRef.current) { + cleanupRef.current(); + } }; }, [node]); -- Gitee From 2b033630d80ff258e5564145f914f417030e91e7 Mon Sep 17 00:00:00 2001 From: tabzzz Date: Thu, 6 Feb 2025 21:12:19 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(mini-markdown-editor):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=B2=98=E8=B4=B4/=E6=8B=96=E6=8B=BD=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=9B=BE=E7=89=87=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/mini-markdown-editor/package.json | 23 ++-- .../src/extensions/codemirror/event.ts | 125 +++++++++++++++++- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/packages/mini-markdown-editor/package.json b/packages/mini-markdown-editor/package.json index 2c7ae0b..73661eb 100644 --- a/packages/mini-markdown-editor/package.json +++ b/packages/mini-markdown-editor/package.json @@ -38,10 +38,14 @@ "emoji-mart": "^5.6.0", "highlight.js": "^11.11.1", "html2pdf.js": "^0.10.2", + "nanoid": "^5.0.9", "react-hotkeys-hook": "^4.6.1", "zustand": "^5.0.3" }, "devDependencies": { + "@codemirror/commands": "^6.8.0", + "@codemirror/lang-markdown": "^6.3.2", + "@codemirror/language-data": "^6.5.1", "@eslint/js": "^9.17.0", "@mini-markdown/ast-parser": "workspace:^1.0.0", "@testing-library/dom": "^10.4.0", @@ -49,6 +53,8 @@ "@testing-library/react": "^16.2.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@uiw/codemirror-extensions-events": "^4.23.7", + "@uiw/react-codemirror": "^4.23.7", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "3.0.5", "eslint": "^9.17.0", @@ -56,27 +62,22 @@ "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", "jsdom": "^26.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "styled-components": "^6.1.14", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5", "vite-plugin-dts": "^4.5.0", - "vitest": "^3.0.5", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "@codemirror/commands": "^6.8.0", - "@codemirror/lang-markdown": "^6.3.2", - "@codemirror/language-data": "^6.5.1", - "@uiw/codemirror-extensions-events": "^4.23.7", - "@uiw/react-codemirror": "^4.23.7" + "vitest": "^3.0.5" }, "peerDependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", "@codemirror/commands": "^6.8.0", "@codemirror/lang-markdown": "^6.3.2", "@codemirror/language-data": "^6.5.1", "@uiw/codemirror-extensions-events": "^4.23.7", - "@uiw/react-codemirror": "^4.23.7" + "@uiw/react-codemirror": "^4.23.7", + "react": "^18.3.1", + "react-dom": "^18.3.1" } } \ No newline at end of file diff --git a/packages/mini-markdown-editor/src/extensions/codemirror/event.ts b/packages/mini-markdown-editor/src/extensions/codemirror/event.ts index abe97ff..7fb05c4 100644 --- a/packages/mini-markdown-editor/src/extensions/codemirror/event.ts +++ b/packages/mini-markdown-editor/src/extensions/codemirror/event.ts @@ -1,4 +1,5 @@ import { EditorView, ViewPlugin } from "@uiw/react-codemirror"; +import { nanoid } from "nanoid"; interface EventOptions { scrollWrapper: string; @@ -9,6 +10,128 @@ interface EventOptions { }>; } +let currentObjectURL: string | null = null; + +// 处理图片 +const handleImageFile = (file: File, view: EditorView) => { + // TODO: 限制图片大小 + // if (file.size > 5 * 1024 * 1024) { + // console.warn("图片大小不能超过5MB!"); + // return; + // } + + if (currentObjectURL) { + URL.revokeObjectURL(currentObjectURL); + } + + const imageUrl = URL.createObjectURL(file); + currentObjectURL = imageUrl; + // alt 设置随机八位 + const imageAlt = nanoid(8); + + // 获取当前选区 + const selection = view.state.selection.main; + + // 直接在 view 中处理插入 + const content = `![${imageAlt}](${imageUrl})`; + + // 直接替换当前选区内容 + view.dispatch({ + changes: { + from: selection.from, + to: selection.to, + insert: content, + }, + }); +}; + +// 创建拖拽插件 +const createDropPlugin = () => { + return ViewPlugin.fromClass( + class { + constructor(view: EditorView) { + view.dom.addEventListener("dragover", (e: DragEvent) => { + e.preventDefault(); + }); + view.dom.addEventListener("drop", (e: DragEvent) => { + e.preventDefault(); + + const files = e.dataTransfer?.files; + if (!files || files.length === 0) return; + + const file = files[0]; + if (!file.type.startsWith("image/")) { + // Only image files are supported + console.warn("只支持图片格式的文件"); + return; + } + // 处理图片文件 + handleImageFile(file, view); + }); + } + + destroy() { + if (currentObjectURL) { + URL.revokeObjectURL(currentObjectURL); + currentObjectURL = null; + } + } + }, + ); +}; + +// 创建粘贴插件 +const createPastePlugin = () => { + return ViewPlugin.fromClass( + class { + constructor(view: EditorView) { + view.dom.addEventListener("paste", (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + // 检查是否有图片内容 + let hasImageContent = false; + for (const item of items) { + if (item.type.startsWith("image/")) { + hasImageContent = true; + break; + } + } + + // 如果没有图片内容,使用默认粘贴行为 + if (!hasImageContent) { + return; + } + + // 阻止默认粘贴行为 + e.preventDefault(); + + for (const item of items) { + if (item.type.startsWith("image/")) { + const file = item.getAsFile(); + if (file) { + handleImageFile(file, view); + break; + } + } + } + }); + } + + destroy() { + if (currentObjectURL) { + URL.revokeObjectURL(currentObjectURL); + currentObjectURL = null; + } + } + }, + ); +}; + export const createEventExtension = (eventOptions: EventOptions): any => { - return eventOptions.scrollWrapper === "editor" ? eventOptions.eventExt : []; + if (eventOptions.scrollWrapper !== "editor") { + return []; + } + + return [eventOptions.eventExt, createDropPlugin(), createPastePlugin()].filter(Boolean); }; -- Gitee From c1977902184de3cb9be0e01421dd6b7b42b9f6e3 Mon Sep 17 00:00:00 2001 From: tabzzz Date: Thu, 6 Feb 2025 21:28:52 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(mini-markdown-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BA=86=E4=B8=80=E6=AC=A1=E7=B2=98=E8=B4=B4/?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E5=BC=95=E8=B5=B7=E7=9A=84=E5=A4=9A=E6=AC=A1?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 粘贴和拖拽事件监听器没有被正确销毁,导致插件实例被多次创建时会出现多个重复的事件监听器 --- .../src/extensions/codemirror/event.ts | 157 +++++++++--------- 1 file changed, 79 insertions(+), 78 deletions(-) diff --git a/packages/mini-markdown-editor/src/extensions/codemirror/event.ts b/packages/mini-markdown-editor/src/extensions/codemirror/event.ts index 7fb05c4..4d13bea 100644 --- a/packages/mini-markdown-editor/src/extensions/codemirror/event.ts +++ b/packages/mini-markdown-editor/src/extensions/codemirror/event.ts @@ -10,119 +10,118 @@ interface EventOptions { }>; } -let currentObjectURL: string | null = null; - -// 处理图片 -const handleImageFile = (file: File, view: EditorView) => { - // TODO: 限制图片大小 - // if (file.size > 5 * 1024 * 1024) { - // console.warn("图片大小不能超过5MB!"); - // return; - // } - - if (currentObjectURL) { - URL.revokeObjectURL(currentObjectURL); +// 实例属性 +// 提供销毁方法,同时有效避免全局 Url 的变量污染 +class ImageHandler { + private currentObjectURL: string | null = null; + + handleImageFile(file: File, view: EditorView) { + // TODO: 限制图片大小(可以外露) + // if (file.size > 5 * 1024 * 1024) { + // console.warn("图片大小不能超过5MB!"); + // return; + // } + + if (this.currentObjectURL) { + URL.revokeObjectURL(this.currentObjectURL); + } + + const imageUrl = URL.createObjectURL(file); + this.currentObjectURL = imageUrl; + //* 生成随机八位 alt + const imageAlt = nanoid(8); + + const selection = view.state.selection.main; + const content = `![${imageAlt}](${imageUrl})`; + + view.dispatch({ + changes: { + from: selection.from, + to: selection.to, + insert: content, + }, + }); } - const imageUrl = URL.createObjectURL(file); - currentObjectURL = imageUrl; - // alt 设置随机八位 - const imageAlt = nanoid(8); - - // 获取当前选区 - const selection = view.state.selection.main; - - // 直接在 view 中处理插入 - const content = `![${imageAlt}](${imageUrl})`; - - // 直接替换当前选区内容 - view.dispatch({ - changes: { - from: selection.from, - to: selection.to, - insert: content, - }, - }); -}; + destroy() { + if (this.currentObjectURL) { + URL.revokeObjectURL(this.currentObjectURL); + } + } +} // 创建拖拽插件 -const createDropPlugin = () => { +const createDropPhotoExtension = () => { return ViewPlugin.fromClass( class { + private handler: ImageHandler; + private onDragOver: (e: DragEvent) => void; + private onDrop: (e: DragEvent) => void; + private view: EditorView; + constructor(view: EditorView) { - view.dom.addEventListener("dragover", (e: DragEvent) => { - e.preventDefault(); - }); - view.dom.addEventListener("drop", (e: DragEvent) => { - e.preventDefault(); + this.view = view; + this.handler = new ImageHandler(); + this.onDragOver = (e: DragEvent) => e.preventDefault(); + this.onDrop = (e: DragEvent) => { + e.preventDefault(); const files = e.dataTransfer?.files; - if (!files || files.length === 0) return; + if (!files?.[0]?.type.startsWith("image/")) return; - const file = files[0]; - if (!file.type.startsWith("image/")) { - // Only image files are supported - console.warn("只支持图片格式的文件"); - return; - } - // 处理图片文件 - handleImageFile(file, view); - }); + this.handler.handleImageFile(files[0], view); + }; + + this.view.dom.addEventListener("dragover", this.onDragOver); + this.view.dom.addEventListener("drop", this.onDrop); } destroy() { - if (currentObjectURL) { - URL.revokeObjectURL(currentObjectURL); - currentObjectURL = null; - } + // 必须要移除事件监听,如果不移除的话会重复绑定 + this.view.dom.removeEventListener("dragover", this.onDragOver); + this.view.dom.removeEventListener("drop", this.onDrop); + this.handler.destroy(); } }, ); }; // 创建粘贴插件 -const createPastePlugin = () => { +const createPastePhotoExtension = () => { return ViewPlugin.fromClass( class { + private handler: ImageHandler; + private onPaste: (e: ClipboardEvent) => void; + private view: EditorView; + constructor(view: EditorView) { - view.dom.addEventListener("paste", (e: ClipboardEvent) => { + this.view = view; + this.handler = new ImageHandler(); + + this.onPaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; - // 检查是否有图片内容 - let hasImageContent = false; - for (const item of items) { - if (item.type.startsWith("image/")) { - hasImageContent = true; - break; - } - } - - // 如果没有图片内容,使用默认粘贴行为 - if (!hasImageContent) { - return; - } - - // 阻止默认粘贴行为 - e.preventDefault(); - for (const item of items) { if (item.type.startsWith("image/")) { + e.preventDefault(); const file = item.getAsFile(); if (file) { - handleImageFile(file, view); + this.handler.handleImageFile(file, view); + //! 只处理第一个图片 break; } } } - }); + }; + + this.view.dom.addEventListener("paste", this.onPaste); } destroy() { - if (currentObjectURL) { - URL.revokeObjectURL(currentObjectURL); - currentObjectURL = null; - } + // 必须要移除事件监听,如果不移除的话会重复绑定 + this.view.dom.removeEventListener("paste", this.onPaste); + this.handler.destroy(); } }, ); @@ -133,5 +132,7 @@ export const createEventExtension = (eventOptions: EventOptions): any => { return []; } - return [eventOptions.eventExt, createDropPlugin(), createPastePlugin()].filter(Boolean); + return [eventOptions.eventExt, createDropPhotoExtension(), createPastePhotoExtension()].filter( + Boolean, + ); }; -- Gitee