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 = ``;
+
+ 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,