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' : ''}![${alt}](${url})\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' : ''}![${alt}](${url})` + + 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} + /> +