Add image upload to Markdown editor: paste, drag-drop, toolbar button
Deploy to LXC / deploy (push) Successful in 22s
Deploy to LXC / deploy (push) Successful in 22s
- /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  Markdown at cursor position Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,37 @@
|
|||||||
let textareaEl: HTMLTextAreaElement;
|
let textareaEl: HTMLTextAreaElement;
|
||||||
let editor: any = null;
|
let editor: any = null;
|
||||||
|
|
||||||
|
async function uploadImage(file: File): Promise<string | null> {
|
||||||
|
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(`\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 () => {
|
onMount(async () => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
@@ -25,12 +56,28 @@
|
|||||||
spellChecker: false,
|
spellChecker: false,
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
placeholder,
|
placeholder,
|
||||||
status: false,
|
status: ['upload'],
|
||||||
minHeight: '300px',
|
minHeight: '300px',
|
||||||
toolbar: [
|
toolbar: [
|
||||||
'bold', 'italic', 'heading', '|',
|
'bold', 'italic', 'heading', '|',
|
||||||
'quote', 'unordered-list', 'ordered-list', '|',
|
'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', '|',
|
'preview', 'side-by-side', 'fullscreen', '|',
|
||||||
'guide'
|
'guide'
|
||||||
],
|
],
|
||||||
@@ -40,6 +87,32 @@
|
|||||||
editor.codemirror.on('change', () => {
|
editor.codemirror.on('change', () => {
|
||||||
value = editor.value();
|
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(() => {
|
onDestroy(() => {
|
||||||
@@ -80,6 +153,9 @@
|
|||||||
.markdown-editor :global(.editor-toolbar button.active) {
|
.markdown-editor :global(.editor-toolbar button.active) {
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
.markdown-editor :global(.editor-statusbar) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
/* Dark mode */
|
/* Dark mode */
|
||||||
:global(.dark) .markdown-editor :global(.CodeMirror) {
|
:global(.dark) .markdown-editor :global(.CodeMirror) {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user