diff --git a/packages/mini-markdown-editor/package.json b/packages/mini-markdown-editor/package.json index 2c7ae0b6cfaef7aca5cafba328811fddd13eabe4..73661ebabfc031112302396d5f34cb589721f7c0 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/assets/images/copy-done.svg b/packages/mini-markdown-editor/src/assets/images/copy-done.svg new file mode 100644 index 0000000000000000000000000000000000000000..1693102689212d1c8655dc0970454a5a1d5979f7 --- /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 0000000000000000000000000000000000000000..b0010fa36ab2258e6cac7dd284fad1249bccc2a2 --- /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 0000000000000000000000000000000000000000..a3561b1bddee859a93334b620474073b9010d98f --- /dev/null +++ b/packages/mini-markdown-editor/src/components/Preview/CopyCodeButton.tsx @@ -0,0 +1,97 @@ +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"; + +interface CopyButtonProps { + content: string; + className?: string; +} + +export const CopyButton: FC = ({ content, className = "" }) => { + const [copied, setCopied] = useState(false); + + 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 ( + + + + ); +}; + +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 448a3cf72eba4bfc41e9048916e1355a4770482f..ebb0797b0dfe92b255268c203271a247b473b486 100644 --- a/packages/mini-markdown-editor/src/components/Preview/index.tsx +++ b/packages/mini-markdown-editor/src/components/Preview/index.tsx @@ -1,15 +1,18 @@ 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"; import { handlePreviewScroll } from "@/utils/handle-scroll"; import { usePreviewTheme } from "@/hooks/use-preview-theme"; import { ConfigContext } from "../providers/config-provider"; +import { CopyButton } from "./CopyCodeButton"; 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( @@ -32,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; @@ -42,6 +46,7 @@ const Preview: FC<{ content: string; isSyncScroll: boolean }> = ({ content, isSy [scrollWrapper, editorView, isSyncScroll], ); + // 鼠标进入预览区域 const handleMouseEnter = useCallback(() => { setScrollWrapper("preview"); }, [setScrollWrapper]); @@ -50,6 +55,68 @@ const Preview: FC<{ content: string; isSyncScroll: boolean }> = ({ content, isSy const { theme } = useContext(ConfigContext); usePreviewTheme(theme as "light" | "dark"); + // 处理代码块复制按钮 + 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) => { + try { + 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"; + + const root = createRoot(copyButtonContainer); + currentRoots.set(copyButtonContainer, root); + + root.render(); + + 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); + }; + }; + + // 防止添加按钮时dom未渲染 + requestAnimationFrame(() => { + addCopyButtons(); + }); + + return () => { + // 组件卸载时执行清理,把清理函数执行 + if (cleanupRef.current) { + cleanupRef.current(); + } + }; + }, [node]); + return ( ; } +// 实例属性 +// 提供销毁方法,同时有效避免全局 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, + }, + }); + } + + destroy() { + if (this.currentObjectURL) { + URL.revokeObjectURL(this.currentObjectURL); + } + } +} + +// 创建拖拽插件 +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) { + 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?.[0]?.type.startsWith("image/")) return; + + this.handler.handleImageFile(files[0], view); + }; + + this.view.dom.addEventListener("dragover", this.onDragOver); + this.view.dom.addEventListener("drop", this.onDrop); + } + + destroy() { + // 必须要移除事件监听,如果不移除的话会重复绑定 + this.view.dom.removeEventListener("dragover", this.onDragOver); + this.view.dom.removeEventListener("drop", this.onDrop); + this.handler.destroy(); + } + }, + ); +}; + +// 创建粘贴插件 +const createPastePhotoExtension = () => { + return ViewPlugin.fromClass( + class { + private handler: ImageHandler; + private onPaste: (e: ClipboardEvent) => void; + private view: EditorView; + + constructor(view: EditorView) { + this.view = view; + this.handler = new ImageHandler(); + + this.onPaste = (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of items) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + const file = item.getAsFile(); + if (file) { + this.handler.handleImageFile(file, view); + //! 只处理第一个图片 + break; + } + } + } + }; + + this.view.dom.addEventListener("paste", this.onPaste); + } + + destroy() { + // 必须要移除事件监听,如果不移除的话会重复绑定 + this.view.dom.removeEventListener("paste", this.onPaste); + this.handler.destroy(); + } + }, + ); +}; + export const createEventExtension = (eventOptions: EventOptions): any => { - return eventOptions.scrollWrapper === "editor" ? eventOptions.eventExt : []; + if (eventOptions.scrollWrapper !== "editor") { + return []; + } + + return [eventOptions.eventExt, createDropPhotoExtension(), createPastePhotoExtension()].filter( + Boolean, + ); }; diff --git a/packages/mini-markdown-editor/src/extensions/codemirror/hotkeys.ts b/packages/mini-markdown-editor/src/extensions/codemirror/hotkeys.ts index 5b675dfc071c99a1e0437f6abc3b157b5099a19a..99fce7a1d0ee52f15d74d31348b73708388c954c 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,