diff --git a/package.json b/package.json index d47ee0d52245e1bd9a8a9cee99d71420ad770f36..e1d189d300684c7c45b3a9b457472b3c2961f80b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "build:ast:play": "pnpm -F @mini-markdown/ast-parser build:play", "dev:editor": "pnpm -F @mini-markdown/editor dev", "build:editor": "pnpm -F @mini-markdown/editor build", + "dev:play": "pnpm -F @mini-markdown/play dev", "prepare": "husky", "lint": "node ./scripts/zx-lint.js", "prettier": "prettier --write ." diff --git a/packages/mini-markdown-editor/src/App.tsx b/packages/mini-markdown-editor/src/App.tsx index 6548f265f5b7d81f7e5da0f155f703cade24dd0a..e9684add0bac8caa3481847f7d8b552d78cfb9ed 100644 --- a/packages/mini-markdown-editor/src/App.tsx +++ b/packages/mini-markdown-editor/src/App.tsx @@ -6,6 +6,7 @@ import { Button, message } from "antd"; // 可根据需要引入不同的主题 import "highlight.js/styles/atom-one-dark.css"; import { ViewUpdate } from "./types/code-mirror"; +import { EditorView } from "@uiw/react-codemirror"; const AppWrapper = styled.div` width: 100%; @@ -43,6 +44,24 @@ const App: FC = () => { console.log(val, view); }; + const handleSave = (content: string, view: EditorView) => { + console.log(content, view); + }; + + const handlePatseUpload = async (file: File, callback: Callback) => { + await new Promise((resolve) => { + setTimeout(() => { + console.log("settimeout 上传成功", file); + resolve({}); + }, 1500); + }); + callback({ + url: "https://www.baidu.com/img/flexible/logo/pc/result@2.png", + alt: "123", + }); + message.success("上传成功"); + }; + return ( @@ -53,6 +72,9 @@ const App: FC = () => { lineNumbers={true} theme={theme as "light" | "dark"} onChange={handleChange} + onSave={handleSave} + onDragUpload={handlePatseUpload} + onPatseUpload={handlePatseUpload} /> ); diff --git a/packages/mini-markdown-editor/src/components/Editor/index.tsx b/packages/mini-markdown-editor/src/components/Editor/index.tsx index 3e4a41ef1b12110b94baccf91c97b92c18591a75..741f4dad1c30344a869e6c86e1d7308a37214ea9 100644 --- a/packages/mini-markdown-editor/src/components/Editor/index.tsx +++ b/packages/mini-markdown-editor/src/components/Editor/index.tsx @@ -7,6 +7,7 @@ import { handleEditorScroll } from "@/utils/handle-scroll"; import { usePersistEditorContent } from "@/hooks/use-persist-editor-content"; import { ConfigContext } from "../providers/config-provider"; import { createEditorExtensions } from "@/extensions/codemirror"; +import { Callback } from "@/types/global-config"; const ScrollWrapper = styled.div<{ $lineNumbers?: boolean; @@ -82,7 +83,8 @@ const Editor: FC<{ isSyncScroll: boolean }> = ({ isSyncScroll }) => { setEditorViewInstance(view); }; - const { onChange } = useContext(ConfigContext); + const { theme, lineNumbers, enableShortcuts, onChange, onDragUpload, onPatseUpload } = + useContext(ConfigContext); const handleChange = (val: string, editView: ViewUpdate) => { // 更新store @@ -110,14 +112,28 @@ const Editor: FC<{ isSyncScroll: boolean }> = ({ isSyncScroll }) => { setScrollWrapper("editor"); }; + // 拖拽上传 + const handleDragUpload = (file: File, Callback: Callback) => { + onDragUpload?.(file, Callback); + }; + // 粘贴上传 + const handlePatseUpload = (file: File, Callback: Callback) => { + onPatseUpload?.(file, Callback); + }; + // 创建编辑器扩展 const extensions = useMemo( - () => createEditorExtensions({ scrollWrapper, eventExt }), + () => + createEditorExtensions({ + enableShortcuts, + scrollWrapper, + eventExt, + onDragUpload: handleDragUpload, + onPasteUpload: handlePatseUpload, + }), [scrollWrapper], ); - const { theme, lineNumbers } = useContext(ConfigContext); - return ( { + const { content, editorView } = useEditorContentStore(); + const saveContent = useSaveContent(); + const { onSave } = useContext(ConfigContext); + + const handleSave = () => { + if (content) { + saveContent(content); + // onSave回调 + if (onSave) { + onSave(content, editorView!); + } + } + }; + + return ( + +
+ + ); +}; + +export default Save; diff --git a/packages/mini-markdown-editor/src/config/global.ts b/packages/mini-markdown-editor/src/config/global.ts index 40a8264f0b5802c59e4dc37dbdbd20c746474587..9b90b351804b52a93d46443c8347f49552bd36ef 100644 --- a/packages/mini-markdown-editor/src/config/global.ts +++ b/packages/mini-markdown-editor/src/config/global.ts @@ -6,4 +6,5 @@ export const defaultGlobalConfig: GlobalConfig = { theme: "light", // 主题 local: true, // 是否开启本地存储 lineNumbers: false, // 是否显示行号 + enableShortcuts: true, // 是否开启快捷键 }; diff --git a/packages/mini-markdown-editor/src/config/toolbar/base.tsx b/packages/mini-markdown-editor/src/config/toolbar/base.tsx index 5c2e3ec4e0ff4d61fe5a9ddbacc03cba0eb0a6eb..9be28135b0f91a0853cccd9c619c10432b4ab490 100644 --- a/packages/mini-markdown-editor/src/config/toolbar/base.tsx +++ b/packages/mini-markdown-editor/src/config/toolbar/base.tsx @@ -21,6 +21,7 @@ import Upload from "@/components/Toolbar/Upload"; import FullScreen from "@/components/Toolbar/FullScreen"; import { Contents, Read, Write, Help, Output } from "@/components/Toolbar/ShowLayout"; import Emoji from "@/components/Toolbar/Emoji"; +import Save from "@/components/Toolbar/Save"; // 快捷键描述 import { Hotkey } from "@/common/hotkeys"; @@ -186,6 +187,10 @@ export const toolbar: ToolbarItem[] = [ type: "fullscreen", component: , }, + { + type: "save", + component: , + }, { type: "write", component: , diff --git a/packages/mini-markdown-editor/src/config/toolbar/event.ts b/packages/mini-markdown-editor/src/config/toolbar/event.ts index dafb4ce8758786dcf048fb8a3699da0c4216aea8..c6c9694d79c89ed344d38a6c2b31528b48dcc6da 100644 --- a/packages/mini-markdown-editor/src/config/toolbar/event.ts +++ b/packages/mini-markdown-editor/src/config/toolbar/event.ts @@ -10,7 +10,7 @@ export const InsertTextEvent = (type: ToolbarType) => { // 上传图片 export const InsertImageEvent = (url: string, alt: string) => { - const content = `![${alt}](${url})`; + const content = `![${alt}](${url})\n`; const selection = { anchor: 2, head: 2 + alt.length, diff --git a/packages/mini-markdown-editor/src/extensions/codemirror/event.d.ts b/packages/mini-markdown-editor/src/extensions/codemirror/event.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..f30d80b7410d3fc14621197c0b7b57b2778de0e1 --- /dev/null +++ b/packages/mini-markdown-editor/src/extensions/codemirror/event.d.ts @@ -0,0 +1,13 @@ +import { Callback } from "@/types/global-config"; +import { EditorView, ViewPlugin } from "@uiw/react-codemirror"; + +export interface EventOptions { + scrollWrapper: string; + eventExt?: ViewPlugin<{ + dom?: HTMLElement; + view: EditorView; + destroy(): void; + }>; + onDragUpload?: (file: File, callback: Callback) => void; + onPasteUpload?: (file: File, callback: Callback) => void; +} diff --git a/packages/mini-markdown-editor/src/extensions/codemirror/event.ts b/packages/mini-markdown-editor/src/extensions/codemirror/event.ts index 4d13bead206a025838ca227355953b503e1ebb94..3d06db00fa4929b1e713b4c7b7e468000cc26f53 100644 --- a/packages/mini-markdown-editor/src/extensions/codemirror/event.ts +++ b/packages/mini-markdown-editor/src/extensions/codemirror/event.ts @@ -1,38 +1,31 @@ import { EditorView, ViewPlugin } from "@uiw/react-codemirror"; import { nanoid } from "nanoid"; - -interface EventOptions { - scrollWrapper: string; - eventExt?: ViewPlugin<{ - dom?: HTMLElement; - view: EditorView; - destroy(): void; - }>; -} +import type { EventOptions } from "./event.d"; +import { Callback } from "@/types/global-config"; // 实例属性 // 提供销毁方法,同时有效避免全局 Url 的变量污染 class ImageHandler { private currentObjectURL: string | null = null; - handleImageFile(file: File, view: EditorView) { - // TODO: 限制图片大小(可以外露) - // if (file.size > 5 * 1024 * 1024) { - // console.warn("图片大小不能超过5MB!"); - // return; - // } - + handleImageFile( + file: File, + view: EditorView, + uploadCallback?: (file: File, callback: Callback) => void, + ) { if (this.currentObjectURL) { URL.revokeObjectURL(this.currentObjectURL); } - const imageUrl = URL.createObjectURL(file); - this.currentObjectURL = imageUrl; - //* 生成随机八位 alt + // 创建临时URL + const temporaryUrl = URL.createObjectURL(file); + this.currentObjectURL = temporaryUrl; const imageAlt = nanoid(8); + // 插入临时图片用作预览 const selection = view.state.selection.main; - const content = `![${imageAlt}](${imageUrl})`; + const content = `![${imageAlt}](${temporaryUrl})\n`; + const insertPos = selection.from; view.dispatch({ changes: { @@ -40,7 +33,45 @@ class ImageHandler { to: selection.to, insert: content, }, + selection: { + anchor: selection.from + content.length, + head: selection.from + content.length, + }, }); + + // 处理上传后的信息 + //! 此回调函数内部提供返回的url,作为新图片的地址 + //! 可选传递alt参数,作为图片的描述 + const handleCallback: Callback = (param: { url: string; alt?: string }) => { + try { + const newContent = `![${param.alt || imageAlt}](${param.url})\n`; + + // 计算需要替换的范围 + const doc = view.state.doc; + const searchStr = content; + const searchPos = doc.slice(insertPos, insertPos + searchStr.length).toString(); + if (searchPos === searchStr) { + view.dispatch({ + changes: { + from: insertPos, + to: insertPos + searchStr.length, + insert: newContent, + }, + }); + } + + // 清理临时URL + URL.revokeObjectURL(temporaryUrl); + this.currentObjectURL = null; + } catch (err) { + console.error("Failed to replace image URL:", err); + } + }; + + // 执行上传回调 + if (uploadCallback) { + uploadCallback(file, handleCallback); + } } destroy() { @@ -51,7 +82,7 @@ class ImageHandler { } // 创建拖拽插件 -const createDropPhotoExtension = () => { +const createDropPhotoExtension = (onDragUpload?: EventOptions["onDragUpload"]) => { return ViewPlugin.fromClass( class { private handler: ImageHandler; @@ -69,7 +100,7 @@ const createDropPhotoExtension = () => { const files = e.dataTransfer?.files; if (!files?.[0]?.type.startsWith("image/")) return; - this.handler.handleImageFile(files[0], view); + this.handler.handleImageFile(files[0], view, onDragUpload); }; this.view.dom.addEventListener("dragover", this.onDragOver); @@ -87,7 +118,7 @@ const createDropPhotoExtension = () => { }; // 创建粘贴插件 -const createPastePhotoExtension = () => { +const createPastePhotoExtension = (onPasteUpload?: EventOptions["onPasteUpload"]) => { return ViewPlugin.fromClass( class { private handler: ImageHandler; @@ -107,8 +138,7 @@ const createPastePhotoExtension = () => { e.preventDefault(); const file = item.getAsFile(); if (file) { - this.handler.handleImageFile(file, view); - //! 只处理第一个图片 + this.handler.handleImageFile(file, view, onPasteUpload); break; } } @@ -132,7 +162,9 @@ export const createEventExtension = (eventOptions: EventOptions): any => { return []; } - return [eventOptions.eventExt, createDropPhotoExtension(), createPastePhotoExtension()].filter( - Boolean, - ); + return [ + eventOptions.eventExt, + createDropPhotoExtension(eventOptions.onDragUpload), + createPastePhotoExtension(eventOptions.onPasteUpload), + ].filter(Boolean); }; diff --git a/packages/mini-markdown-editor/src/extensions/codemirror/index.ts b/packages/mini-markdown-editor/src/extensions/codemirror/index.ts index 1247396e9df633681c6ab7bed2c917d4b18894c8..f092db47f83c94d12d94a92e7b66f801e8fd9750 100644 --- a/packages/mini-markdown-editor/src/extensions/codemirror/index.ts +++ b/packages/mini-markdown-editor/src/extensions/codemirror/index.ts @@ -1,28 +1,36 @@ import { Extension } from "@codemirror/state"; -import { EditorView, ViewPlugin } from "@uiw/react-codemirror"; +import { EditorView } from "@uiw/react-codemirror"; import { history } from "@codemirror/commands"; import { createMarkdownExtension } from "./markdown"; import { createHotkeysExtension } from "./hotkeys"; import { createEventExtension } from "./event"; +import type { EventOptions } from "./event.d"; -interface ExtensionOptions { - scrollWrapper?: string; - eventExt?: ViewPlugin<{ - dom?: HTMLElement; - view: EditorView; - destroy(): void; - }>; +interface ExtensionOptions extends EventOptions { + enableShortcuts?: boolean; } -export const createEditorExtensions = (options: ExtensionOptions = {}): Extension[] => { - const { scrollWrapper = "editor", eventExt } = options; +export const createEditorExtensions = (options: ExtensionOptions): Extension[] => { + const { + scrollWrapper = "editor", + eventExt, + enableShortcuts, + onDragUpload, + onPasteUpload, + } = options; - return [ - createHotkeysExtension(), + // 创建基础扩展数组 + const extensions: Extension[] = [ createMarkdownExtension(), - createEventExtension({ scrollWrapper, eventExt }), + createEventExtension({ scrollWrapper, eventExt, onDragUpload, onPasteUpload }), history(), - //? 自动换行,可以考虑添加配置项 EditorView.lineWrapping, ]; + + // 是否开启快捷键支持 + if (enableShortcuts) { + extensions.push(createHotkeysExtension()); + } + + return extensions; }; diff --git a/packages/mini-markdown-editor/src/hooks/use-persist-editor-content.ts b/packages/mini-markdown-editor/src/hooks/use-persist-editor-content.ts index 280b942ab1ad736738227df09e02df407492c94f..d44a836e8d204f1c6e43bc1ab7921a17956bb2ea 100644 --- a/packages/mini-markdown-editor/src/hooks/use-persist-editor-content.ts +++ b/packages/mini-markdown-editor/src/hooks/use-persist-editor-content.ts @@ -20,7 +20,6 @@ export const usePersistEditorContent = () => { // 获取内容 const getContent = (): string => { - if (!local) return ""; return localStorage.getItem(EDITOR_CONTENT_KEY) ?? ""; }; diff --git a/packages/mini-markdown-editor/src/hooks/use-save-content.ts b/packages/mini-markdown-editor/src/hooks/use-save-content.ts new file mode 100644 index 0000000000000000000000000000000000000000..d47fab8e5f338ee3bdbad34b7079015200ebc7ec --- /dev/null +++ b/packages/mini-markdown-editor/src/hooks/use-save-content.ts @@ -0,0 +1,15 @@ +import { EDITOR_CONTENT_KEY } from "@/common"; +import { safeLocalStorage } from "@/utils/storage"; +import { useDebounceFn } from "ahooks"; + +export const useSaveContent = () => { + const localStorage = safeLocalStorage(); + const { run: saveContent } = useDebounceFn( + (content: string) => { + localStorage.setItem(EDITOR_CONTENT_KEY, content); + }, + { wait: 300 }, + ); + + return saveContent; +}; diff --git a/packages/mini-markdown-editor/src/types/global-config.ts b/packages/mini-markdown-editor/src/types/global-config.ts index d6ede9510e977ceb208d5b7ba48d952927dd4873..d6e626dd9db0271784c58c403e8a492938913e02 100644 --- a/packages/mini-markdown-editor/src/types/global-config.ts +++ b/packages/mini-markdown-editor/src/types/global-config.ts @@ -1,4 +1,4 @@ -import { ViewUpdate } from "@uiw/react-codemirror"; +import { EditorView, ViewUpdate } from "@uiw/react-codemirror"; import { ToolbarType } from "./toolbar"; export interface GlobalConfig { @@ -28,6 +28,11 @@ export interface GlobalConfig { * @type {boolean} */ lineNumbers?: boolean; + /** + * 是否开启快捷键支持 + * @type {boolean} + */ + enableShortcuts?: boolean; /** * 改变编辑器内容时触发 * @type {(value: string, editorView: ViewUpdate) => void} @@ -43,11 +48,16 @@ export interface GlobalConfig { * @type {(file: File, callback: Callback) => void} */ onDragUpload?: (file: File, callback: Callback) => void; + /** + * 粘贴上传图片时触发 + * @type {(file: File, callback: Callback) => void} + */ + onPatseUpload?: (file: File, callback: Callback) => void; /** * 保存触发 - * @type {(value: string, editorView: ViewUpdate) => void} + * @type {(value: string, editorView: EditorView) => void} */ - onSave?: (value: string, editorView: ViewUpdate) => void; + onSave?: (value: string, editorView: EditorView) => void; } export type Callback = (param: { url: string; alt?: string }) => void; diff --git a/packages/mini-markdown-editor/src/types/toolbar.ts b/packages/mini-markdown-editor/src/types/toolbar.ts index 7f2ca1e67b098b6079c33eb3cbb965cde1c7ea09..01823b6f7839085f78c37ca0978323494e397f0a 100644 --- a/packages/mini-markdown-editor/src/types/toolbar.ts +++ b/packages/mini-markdown-editor/src/types/toolbar.ts @@ -55,4 +55,5 @@ export type ToolbarType = | "contents" | "help" | "output" - | "emoji"; + | "emoji" + | "save"; diff --git a/packages/mini-markdown-play/src/pages/ui-test.tsx b/packages/mini-markdown-play/src/pages/ui-test.tsx index 7a307fab3526b0640087148ad454c818a925f01c..661c76fde3f2943bbd273ae6e67daa1573099d68 100644 --- a/packages/mini-markdown-play/src/pages/ui-test.tsx +++ b/packages/mini-markdown-play/src/pages/ui-test.tsx @@ -39,6 +39,20 @@ const App: FC = () => { console.log(val, view); }; + const handlePatseUpload = async (file: File, callback: Callback) => { + await new Promise((resolve) => { + setTimeout(() => { + console.log("settimeout 上传成功", file); + resolve({}); + }, 1500); + }); + callback({ + url: "https://www.baidu.com/img/flexible/logo/pc/result@2.png", + alt: "123", + }); + message.success("上传成功"); + }; + return ( @@ -48,6 +62,9 @@ const App: FC = () => { local={true} theme={theme} onChange={handleChange} + enableShortcuts={true} + onDragUpload={handlePatseUpload} + onPatseUpload={handlePatseUpload} /> );