From 03e23cf0c02bad8fbf007f9e059230a63c710919 Mon Sep 17 00:00:00 2001 From: grabowski Date: Mon, 13 Apr 2026 16:15:16 +0700 Subject: [PATCH] Add image upload to Markdown editor: paste, drag-drop, toolbar button - /api/upload-image endpoint saves images and returns URL - Paste images from clipboard directly into the editor - Drag and drop images onto the editor - Toolbar image button opens file picker for manual upload - Uploaded images inserted as ![alt](url) Markdown at cursor position Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/components/ui/MarkdownEditor.svelte | 80 ++++++++++++++++++++- src/routes/api/upload-image/+server.ts | 21 ++++++ 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/routes/api/upload-image/+server.ts diff --git a/src/lib/components/ui/MarkdownEditor.svelte b/src/lib/components/ui/MarkdownEditor.svelte index 7c8006f..c1dc4f2 100644 --- a/src/lib/components/ui/MarkdownEditor.svelte +++ b/src/lib/components/ui/MarkdownEditor.svelte @@ -13,6 +13,37 @@ let textareaEl: HTMLTextAreaElement; let editor: any = null; + async function uploadImage(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + try { + const res = await fetch('/api/upload-image', { method: 'POST', body: formData }); + if (!res.ok) throw new Error('Upload failed'); + const { url } = await res.json(); + return url; + } catch { + alert('Image upload failed'); + return null; + } + } + + function insertImageAtCursor(url: string, altText: string) { + if (!editor) return; + const cm = editor.codemirror; + const pos = cm.getCursor(); + cm.replaceRange(`![${altText}](${url})\n`, pos); + value = editor.value(); + } + + async function handleFiles(files: FileList | File[]) { + for (const file of files) { + if (!file.type.startsWith('image/')) continue; + const url = await uploadImage(file); + if (url) insertImageAtCursor(url, file.name.replace(/\.[^.]+$/, '')); + } + } + onMount(async () => { if (!browser) return; @@ -25,12 +56,28 @@ spellChecker: false, autofocus: false, placeholder, - status: false, + status: ['upload'], minHeight: '300px', toolbar: [ 'bold', 'italic', 'heading', '|', 'quote', 'unordered-list', 'ordered-list', '|', - 'link', 'image', 'table', 'code', '|', + 'link', + { + name: 'upload-image', + action: () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.multiple = true; + input.onchange = () => { + if (input.files) handleFiles(input.files); + }; + input.click(); + }, + className: 'fa fa-image', + title: 'Upload Image' + }, + 'table', 'code', '|', 'preview', 'side-by-side', 'fullscreen', '|', 'guide' ], @@ -40,6 +87,32 @@ editor.codemirror.on('change', () => { value = editor.value(); }); + + // Drag and drop images + const cm = editor.codemirror; + cm.on('drop', (_: any, e: DragEvent) => { + if (e.dataTransfer?.files?.length) { + e.preventDefault(); + handleFiles(e.dataTransfer.files); + } + }); + + // Paste images + cm.on('paste', (_: any, e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + const imageFiles: File[] = []; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + if (imageFiles.length > 0) { + e.preventDefault(); + handleFiles(imageFiles); + } + }); }); onDestroy(() => { @@ -80,6 +153,9 @@ .markdown-editor :global(.editor-toolbar button.active) { background: #e5e7eb; } + .markdown-editor :global(.editor-statusbar) { + display: none; + } /* Dark mode */ :global(.dark) .markdown-editor :global(.CodeMirror) { background: #374151; diff --git a/src/routes/api/upload-image/+server.ts b/src/routes/api/upload-image/+server.ts new file mode 100644 index 0000000..d9fe713 --- /dev/null +++ b/src/routes/api/upload-image/+server.ts @@ -0,0 +1,21 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { saveImage } from '$lib/server/uploads.js'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) error(401, 'Not authenticated'); + + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file || file.size === 0) { + error(400, 'No file provided'); + } + + try { + const { filePath } = await saveImage(file, 'devices'); + return json({ url: filePath }); + } catch (err) { + error(400, err instanceof Error ? err.message : 'Upload failed'); + } +};