03e23cf0c0
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>
188 lines
4.6 KiB
Svelte
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(`\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>
|