diff --git a/src/components/editor/codemirror/codemirror.tsx b/src/components/editor/codemirror/codemirror.tsx
index 4099ce36e..278782e10 100644
--- a/src/components/editor/codemirror/codemirror.tsx
+++ b/src/components/editor/codemirror/codemirror.tsx
@@ -69,7 +69,11 @@ export const CodemirrorEditor = defineComponent({
diff --git a/src/components/editor/codemirror/paste-image-extension.ts b/src/components/editor/codemirror/paste-image-extension.ts
new file mode 100644
index 000000000..1123381e2
--- /dev/null
+++ b/src/components/editor/codemirror/paste-image-extension.ts
@@ -0,0 +1,300 @@
+import { EditorView } from '@codemirror/view'
+
+import { RESTManager } from '~/utils'
+
+let dragOverlay: HTMLDivElement | null = null
+
+function showDragOverlay(container: HTMLElement) {
+ if (dragOverlay) return
+
+ dragOverlay = document.createElement('div')
+ dragOverlay.className = 'editor-drag-overlay'
+ dragOverlay.innerHTML = `
+
+ `
+
+ const style = document.createElement('style')
+ style.textContent = `
+ .editor-drag-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(59, 130, 246, 0.1);
+ backdrop-filter: blur(2px);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px dashed rgba(59, 130, 246, 0.5);
+ border-radius: 8px;
+ pointer-events: none;
+ }
+ .editor-drag-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ color: rgba(59, 130, 246, 0.8);
+ }
+ .editor-drag-text {
+ font-size: 18px;
+ font-weight: 500;
+ }
+ .dark .editor-drag-overlay {
+ background: rgba(59, 130, 246, 0.15);
+ border-color: rgba(59, 130, 246, 0.6);
+ }
+ .dark .editor-drag-content {
+ color: rgba(96, 165, 250, 0.9);
+ }
+ `
+ document.head.appendChild(style)
+
+ container.appendChild(dragOverlay)
+}
+
+function hideDragOverlay() {
+ if (dragOverlay) {
+ dragOverlay.remove()
+ dragOverlay = null
+ }
+}
+
+function validateFile(
+ file: File,
+ maxSizeMB: number,
+): { valid: boolean; error?: string } {
+ if (!file.type.startsWith('image/')) {
+ return { valid: false, error: '只能上传图片文件哦~' }
+ }
+
+ const maxSize = maxSizeMB * 1024 * 1024
+ if (file.size > maxSize) {
+ const sizeMB = (file.size / 1024 / 1024).toFixed(2)
+ return {
+ valid: false,
+ error: `图片大小 ${sizeMB}MB 超过限制 ${maxSizeMB}MB`,
+ }
+ }
+
+ return { valid: true }
+}
+
+async function uploadImage(file: File): Promise<{
+ url: string
+ name: string
+ storage: 'local' | 's3'
+} | null> {
+ try {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ const response = await RESTManager.api.objects.upload.post<{
+ url: string
+ name: string
+ storage: 'local' | 's3'
+ }>({
+ params: {
+ type: 'photo',
+ },
+ data: formData,
+ timeout: 60000,
+ getResponse: false,
+ })
+
+ window.message.success(
+ `上传成功!存储位置: ${response.storage === 's3' ? 'S3' : '本地'}`,
+ )
+
+ return response
+ } catch (error: any) {
+ window.message.error(`上传失败: ${error.message || '未知错误'}`)
+ return null
+ }
+}
+
+function insertImageMarkdown(view: EditorView, alt: string, url: string) {
+ const { state } = view
+ const { from } = state.selection.main
+ const line = state.doc.lineAt(from)
+
+ const insertPos = line.to
+ const needsNewline = line.text.length > 0
+ const insert = `${needsNewline ? '\n' : ''}\n`
+
+ view.dispatch({
+ changes: { from: insertPos, to: insertPos, insert },
+ selection: {
+ anchor: insertPos + insert.length,
+ },
+ })
+}
+
+async function handleFilesUpload(files: File[], view: EditorView) {
+ if (files.length === 0) {
+ return
+ }
+
+ let maxSizeMB = 10
+ try {
+ const config = await RESTManager.api.options('imageBedOptions').get()
+ if (config?.data?.maxSizeMB) {
+ maxSizeMB = config.data.maxSizeMB
+ }
+ } catch (_error) {
+ console.warn('Failed to fetch image bed config, using default 10MB')
+ }
+
+ if (files.length > 1) {
+ window.message.info(`检测到 ${files.length} 张图片,开始上传...`)
+ }
+
+ let successCount = 0
+ let failCount = 0
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i]
+
+ const validation = validateFile(file, maxSizeMB)
+ if (!validation.valid) {
+ window.message.error(`${file.name}: ${validation.error}`)
+ failCount++
+ continue
+ }
+
+ const loadingMessage = window.message.loading(
+ `正在上传 ${file.name} (${i + 1}/${files.length})...`,
+ {
+ duration: 0,
+ },
+ )
+
+ const result = await uploadImage(file)
+
+ loadingMessage.destroy()
+
+ if (result) {
+ const alt = file.name.replace(/\.[^/.]+$/, '')
+ insertImageMarkdown(view, alt, result.url)
+ successCount++
+ } else {
+ failCount++
+ }
+ }
+
+ if (successCount > 0) {
+ if (failCount > 0) {
+ window.message.warning(
+ `上传完成!成功 ${successCount} 张,失败 ${failCount} 张`,
+ )
+ } else {
+ window.message.success(`成功上传 ${successCount} 张图片`)
+ }
+ } else if (failCount > 0) {
+ window.message.error(`所有图片上传失败`)
+ }
+}
+
+export function createPasteImageExtension() {
+ let dragCounter = 0
+
+ return EditorView.domEventHandlers({
+ paste: (event: ClipboardEvent, view: EditorView) => {
+ const items = event.clipboardData?.items
+ if (!items) return false
+
+ const imageItems: DataTransferItem[] = []
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].type.startsWith('image/')) {
+ imageItems.push(items[i])
+ }
+ }
+
+ if (imageItems.length === 0) {
+ return false
+ }
+
+ event.preventDefault()
+
+ const files: File[] = []
+ for (const item of imageItems) {
+ const file = item.getAsFile()
+ if (file) {
+ files.push(file)
+ }
+ }
+
+ handleFilesUpload(files, view)
+
+ return true
+ },
+
+ dragover: (event: DragEvent, _view: EditorView) => {
+ const types = event.dataTransfer?.types
+ if (types && types.includes('Files')) {
+ event.preventDefault()
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = 'copy'
+ }
+ }
+ return false
+ },
+
+ dragenter: (event: DragEvent, view: EditorView) => {
+ const types = event.dataTransfer?.types
+ if (types && types.includes('Files')) {
+ dragCounter++
+ if (dragCounter === 1) {
+ showDragOverlay(view.dom)
+ }
+ }
+ return false
+ },
+
+ dragleave: (event: DragEvent) => {
+ const types = event.dataTransfer?.types
+ if (types && types.includes('Files')) {
+ dragCounter--
+ if (dragCounter === 0) {
+ hideDragOverlay()
+ }
+ }
+ return false
+ },
+
+ drop: (event: DragEvent, view: EditorView) => {
+ dragCounter = 0
+ hideDragOverlay()
+
+ const files = event.dataTransfer?.files
+ if (!files || files.length === 0) {
+ return false
+ }
+
+ const imageFiles: File[] = []
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i]
+ if (file.type.startsWith('image/')) {
+ imageFiles.push(file)
+ }
+ }
+
+ if (imageFiles.length === 0) {
+ return false
+ }
+
+ event.preventDefault() // anti browser open pic
+ handleFilesUpload(imageFiles, view)
+ return true
+ },
+ })
+}
diff --git a/src/components/editor/codemirror/use-codemirror.ts b/src/components/editor/codemirror/use-codemirror.ts
index 033bb65fe..dcdd79370 100644
--- a/src/components/editor/codemirror/use-codemirror.ts
+++ b/src/components/editor/codemirror/use-codemirror.ts
@@ -22,6 +22,7 @@ import {
import { createToolbarKeymapExtension } from '../toolbar'
import { useEditorConfig } from '../universal/use-editor-setting'
import { codemirrorReconfigureExtension } from './extension'
+import { createPasteImageExtension } from './paste-image-extension'
import { syntaxTheme } from './syntax-highlight'
import { useCodeMirrorConfigureFonts } from './use-auto-fonts'
import { useCodeMirrorAutoToggleTheme } from './use-auto-theme'
@@ -131,6 +132,8 @@ export const useCodeMirror = (
...codemirrorReconfigureExtension,
+ createPasteImageExtension(),
+
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.changes) {
diff --git a/src/components/editor/toolbar/image-upload-modal.tsx b/src/components/editor/toolbar/image-upload-modal.tsx
new file mode 100644
index 000000000..c8e934ab1
--- /dev/null
+++ b/src/components/editor/toolbar/image-upload-modal.tsx
@@ -0,0 +1,231 @@
+import { NButton, NInput, NModal, NSpace, useMessage } from 'naive-ui'
+import { defineComponent, ref, watch } from 'vue'
+import type { PropType } from 'vue'
+
+import { RESTManager } from '~/utils'
+
+export interface ImageUploadResult {
+ alt: string
+ url: string
+}
+
+export const ImageUploadModal = defineComponent({
+ name: 'ImageUploadModal',
+ props: {
+ show: {
+ type: Boolean,
+ required: true,
+ },
+ onClose: {
+ type: Function as PropType<() => void>,
+ required: true,
+ },
+ onConfirm: {
+ type: Function as PropType<(result: ImageUploadResult) => void>,
+ required: true,
+ },
+ },
+ setup(props) {
+ const message = useMessage()
+ const alt = ref('')
+ const url = ref('')
+ const uploading = ref(false)
+ const uploadedFileName = ref('')
+ const uploadedFileStorage = ref<'local' | 's3'>('local')
+ const isUrlLocked = ref(false)
+
+ const resetState = () => {
+ alt.value = ''
+ url.value = ''
+ uploading.value = false
+ uploadedFileName.value = ''
+ uploadedFileStorage.value = 'local'
+ isUrlLocked.value = false
+ }
+
+ watch(
+ () => props.show,
+ (newShow, oldShow) => {
+ if (!newShow && oldShow) {
+ if (uploadedFileName.value && url.value) {
+ deleteUploadedFile(
+ uploadedFileName.value,
+ uploadedFileStorage.value,
+ )
+ }
+ resetState()
+ }
+ },
+ )
+
+ const deleteUploadedFile = async (
+ filename: string,
+ storage: 'local' | 's3',
+ ) => {
+ try {
+ const type = 'photo'
+ await RESTManager.api.objects(type, filename).delete({
+ params: {
+ storage,
+ url: storage === 's3' ? url.value : undefined,
+ },
+ })
+ } catch (error) {
+ console.error('Failed to delete uploaded file:', error)
+ }
+ }
+
+ const handleUpload = async () => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = 'image/*'
+
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0]
+ if (!file) {
+ return
+ }
+
+ if (!file.type.startsWith('image/')) {
+ message.error('请选择图片文件')
+ return
+ }
+
+ let maxSizeMB = 10
+ try {
+ const config = await RESTManager.api
+ .options('imageBedOptions')
+ .get()
+ if (config?.data?.maxSizeMB) {
+ maxSizeMB = config.data.maxSizeMB
+ }
+ } catch (_error) {
+ console.warn('Failed to fetch image bed config, using default 10MB')
+ }
+
+ const maxSize = maxSizeMB * 1024 * 1024
+ if (file.size > maxSize) {
+ const sizeMB = (file.size / 1024 / 1024).toFixed(2)
+ message.error(`图片大小 ${sizeMB}MB 超过限制 ${maxSizeMB}MB`)
+ return
+ }
+
+ uploading.value = true
+
+ try {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ const response = await RESTManager.api.objects.upload.post<{
+ url: string
+ name: string
+ storage: 'local' | 's3'
+ }>({
+ params: {
+ type: 'photo',
+ },
+ data: formData,
+ })
+
+ url.value = response.url
+ uploadedFileName.value = response.name
+ uploadedFileStorage.value = response.storage
+ isUrlLocked.value = true
+
+ message.success(
+ `上传成功!存储位置: ${response.storage === 's3' ? 'S3' : '本地'}`,
+ )
+ } catch (error: any) {
+ message.error(`上传失败: ${error.message || '未知错误'}`)
+ } finally {
+ uploading.value = false
+ }
+ }
+
+ input.click()
+ }
+
+ const handleConfirm = () => {
+ if (!url.value.trim()) {
+ message.error('请输入图片链接或上传图片')
+ return
+ }
+
+ props.onConfirm({
+ alt: alt.value.trim() || '图片',
+ url: url.value.trim(),
+ })
+
+ uploadedFileName.value = ''
+ resetState()
+ }
+
+ const handleCancel = () => {
+ props.onClose()
+ }
+
+ return () => (
+ {
+ if (!show) {
+ props.onClose()
+ }
+ }}
+ preset="card"
+ title="插入图片"
+ style={{ width: '500px' }}
+ >
+ {{
+ default: () => (
+
+
+
图片描述(Alt)
+
(alt.value = val)}
+ placeholder="请输入图片描述"
+ clearable
+ />
+
+
+
+
+ 图片链接
+
+ {uploading.value ? '上传中...' : '上传图片'}
+
+
+
(url.value = val)}
+ placeholder="https://example.com/image.jpg"
+ clearable
+ disabled={isUrlLocked.value}
+ />
+
+
+ ),
+ footer: () => (
+
+ 取消
+
+ 插入
+
+
+ ),
+ }}
+
+ )
+ },
+})
diff --git a/src/components/editor/toolbar/markdown-commands.ts b/src/components/editor/toolbar/markdown-commands.ts
index fa487ef34..9daeb2368 100644
--- a/src/components/editor/toolbar/markdown-commands.ts
+++ b/src/components/editor/toolbar/markdown-commands.ts
@@ -234,6 +234,26 @@ export const commands = {
return true
},
+ image: (view: EditorView, alt: string, url: string) => {
+ const { state } = view
+ const { from } = state.selection.main
+ const line = state.doc.lineAt(from)
+
+ const insertPos = line.to
+ const needsNewline = line.text.length > 0
+ const insert = `${needsNewline ? '\n' : ''}`
+
+ view.dispatch({
+ changes: { from: insertPos, to: insertPos, insert },
+ selection: {
+ anchor: insertPos + insert.length,
+ },
+ })
+
+ view.focus()
+ return true
+ },
+
// managed by historyKeymap
undo: (_view: EditorView) => {
return true
diff --git a/src/components/editor/toolbar/toolbar.tsx b/src/components/editor/toolbar/toolbar.tsx
index a79b01d69..080423da0 100644
--- a/src/components/editor/toolbar/toolbar.tsx
+++ b/src/components/editor/toolbar/toolbar.tsx
@@ -2,12 +2,14 @@ import { NPopover } from 'naive-ui'
import { defineComponent, ref } from 'vue'
import type { EditorView } from '@codemirror/view'
import type { Component, PropType } from 'vue'
+import type { ImageUploadResult } from './image-upload-modal'
import { redo, undo } from '@codemirror/commands'
import Bold from '@vicons/fa/Bold'
import Code from '@vicons/fa/Code'
import FileCode from '@vicons/fa/FileCode'
import Heading from '@vicons/fa/Heading'
+import Image from '@vicons/fa/Image'
import Italic from '@vicons/fa/Italic'
import Link from '@vicons/fa/Link'
import ListOl from '@vicons/fa/ListOl'
@@ -22,6 +24,7 @@ import UndoAlt from '@vicons/fa/UndoAlt'
import { Icon } from '@vicons/utils'
import { EmojiPicker } from './emoji-picker'
+import { ImageUploadModal } from './image-upload-modal'
import { commands } from './markdown-commands'
interface ToolbarButton {
@@ -43,6 +46,7 @@ export const MarkdownToolbar = defineComponent({
setup(props) {
const emojiPickerVisible = ref(false)
const emojiButtonRef = ref()
+ const imageUploadVisible = ref(false)
const executeCommand = (commandFn: (view: EditorView) => boolean) => {
if (props.editorView) {
@@ -57,6 +61,13 @@ export const MarkdownToolbar = defineComponent({
emojiPickerVisible.value = false
}
+ const handleImageUpload = (result: ImageUploadResult) => {
+ if (props.editorView) {
+ commands.image(props.editorView, result.alt, result.url)
+ }
+ imageUploadVisible.value = false
+ }
+
const buttons: ToolbarButton[] = [
{
icon: Smile,
@@ -95,6 +106,14 @@ export const MarkdownToolbar = defineComponent({
title: '链接',
shortcut: 'Ctrl+K',
action: () => executeCommand(commands.link),
+ },
+ {
+ icon: Image,
+ title: '图片',
+ shortcut: 'Ctrl+Shift+I',
+ action: () => {
+ imageUploadVisible.value = true
+ },
divider: true,
},
{
@@ -255,6 +274,12 @@ export const MarkdownToolbar = defineComponent({
}}
+ (imageUploadVisible.value = false)}
+ onConfirm={handleImageUpload}
+ />
+