Add image upload to Markdown editor: paste, drag-drop, toolbar button
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 ![alt](url) Markdown at cursor position

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 16:15:16 +07:00
parent bc73595018
commit 03e23cf0c0
2 changed files with 99 additions and 2 deletions
+78 -2
View File
@@ -13,6 +13,37 @@
let textareaEl: HTMLTextAreaElement;
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(`![${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;