Files
buildfor_life_repair/src/lib/components/ui/MarkdownEditor.svelte
T
grabowski 03e23cf0c0
Deploy to LXC / deploy (push) Successful in 22s
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) <noreply@anthropic.com>
2026-04-13 16:15:16 +07:00

188 lines
4.6 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
interface Props {
name?: string;
value?: string;
placeholder?: string;
}
let { name = 'content', value = $bindable(''), placeholder = 'Write in Markdown...' }: Props = $props();
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;
const EasyMDE = (await import('easymde')).default;
await import('easymde/dist/easymde.min.css');
editor = new EasyMDE({
element: textareaEl,
initialValue: value,
spellChecker: false,
autofocus: false,
placeholder,
status: ['upload'],
minHeight: '300px',
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'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'
],
sideBySideFullscreen: false
});
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(() => {
if (editor) {
editor.toTextArea();
editor = null;
}
});
</script>
<div class="markdown-editor">
<textarea bind:this={textareaEl} id={name}></textarea>
<input type="hidden" {name} value={value} />
</div>
<style>
.markdown-editor :global(.EasyMDEContainer) {
border: none;
}
.markdown-editor :global(.EasyMDEContainer .CodeMirror) {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-family: ui-monospace, monospace;
font-size: 0.875rem;
}
.markdown-editor :global(.editor-toolbar) {
border: 1px solid #d1d5db;
border-bottom: none;
border-radius: 0.375rem 0.375rem 0 0;
opacity: 1;
}
.markdown-editor :global(.editor-toolbar button) {
color: #6b7280 !important;
}
.markdown-editor :global(.editor-toolbar button:hover) {
background: #f3f4f6;
}
.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;
color: #f3f4f6;
border-color: #4b5563;
}
:global(.dark) .markdown-editor :global(.editor-toolbar) {
background: #1f2937;
border-color: #4b5563;
}
:global(.dark) .markdown-editor :global(.editor-toolbar button) {
color: #9ca3af !important;
}
:global(.dark) .markdown-editor :global(.editor-toolbar button:hover) {
background: #374151;
}
:global(.dark) .markdown-editor :global(.CodeMirror-cursor) {
border-color: #f3f4f6;
}
:global(.dark) .markdown-editor :global(.editor-preview) {
background: #374151;
color: #f3f4f6;
}
:global(.dark) .markdown-editor :global(.editor-preview-side) {
background: #374151;
color: #f3f4f6;
border-color: #4b5563;
}
</style>