Skip to content

Commit 844e380

Browse files
aster-voidclaude
andcommitted
feat: add inline image upload to article editor (paste + button)
Writers can now attach images directly in article content via Ctrl+V paste or the "Attach image" toolbar button. Uses UUID-tagged placeholders to safely handle concurrent uploads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 13818da commit 844e380

File tree

1 file changed

+126
-1
lines changed

1 file changed

+126
-1
lines changed

src/lib/components/article-form/ArticleEditor.svelte

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import { ImagePlus, Loader2 } from "lucide-svelte";
23
import Markdown from "../Markdown.svelte";
34
import ImageUpload from "../image-upload.svelte";
45
import {
@@ -7,6 +8,9 @@
78
generateSlug,
89
validateArticleSlug,
910
} from "$lib/shared/logic/slugs";
11+
import { upload } from "$lib/data/private/storage.remote";
12+
import { isAcceptedImageType, inferImageType } from "$lib/shared/logic/image";
13+
import { arrayBufferToBase64, compressImage } from "$lib/shared/logic/image-processing";
1014
1115
let {
1216
title = $bindable(""),
@@ -33,6 +37,91 @@
3337
} = $props();
3438
3539
let showPreview = $state(false);
40+
let textareaEl = $state<HTMLTextAreaElement | null>(null);
41+
let imageUploading = $state(false);
42+
let imageError = $state<string | null>(null);
43+
44+
/** Upload an image and insert markdown at the cursor position in the textarea */
45+
async function uploadAndInsert(file: File) {
46+
const resolvedType = isAcceptedImageType(file.type) ? file.type : inferImageType(file.name);
47+
if (!resolvedType) {
48+
imageError = "Unsupported image format";
49+
return;
50+
}
51+
52+
imageError = null;
53+
imageUploading = true;
54+
55+
// Remember cursor position before async work
56+
const cursorPos = textareaEl?.selectionStart ?? content.length;
57+
58+
// UUID-tagged placeholder so concurrent uploads never collide
59+
const id = crypto.randomUUID();
60+
const placeholder = `![Uploading...](upload:${id})`;
61+
content = content.slice(0, cursorPos) + placeholder + content.slice(cursorPos);
62+
63+
try {
64+
const processedFile = await compressImage(file);
65+
const arrayBuffer = await processedFile.arrayBuffer();
66+
const base64 = arrayBufferToBase64(arrayBuffer);
67+
const uploadType = isAcceptedImageType(processedFile.type) ? processedFile.type : resolvedType;
68+
const result = await upload({
69+
data: base64,
70+
type: uploadType,
71+
name: processedFile.name,
72+
folder: "articles",
73+
});
74+
75+
// Replace this specific placeholder with actual markdown image
76+
const markdown = `![](${result.url})`;
77+
content = content.replace(placeholder, markdown);
78+
79+
// Move cursor after the inserted image
80+
const newPos = content.indexOf(markdown, cursorPos) + markdown.length;
81+
requestAnimationFrame(() => {
82+
if (textareaEl) {
83+
textareaEl.focus();
84+
textareaEl.selectionStart = newPos;
85+
textareaEl.selectionEnd = newPos;
86+
}
87+
});
88+
} catch {
89+
// Remove this specific placeholder on failure
90+
content = content.replace(placeholder, "");
91+
imageError = "Image upload failed. Please try again.";
92+
} finally {
93+
imageUploading = false;
94+
}
95+
}
96+
97+
function handleContentPaste(e: ClipboardEvent) {
98+
const items = e.clipboardData?.items;
99+
if (!items) return;
100+
for (const item of items) {
101+
if (item.type.startsWith("image/")) {
102+
const file = item.getAsFile();
103+
if (file) {
104+
e.preventDefault();
105+
uploadAndInsert(file).catch(console.error);
106+
return;
107+
}
108+
}
109+
}
110+
}
111+
112+
let fileInputEl = $state<HTMLInputElement | null>(null);
113+
114+
function handleAttachClick() {
115+
fileInputEl?.click();
116+
}
117+
118+
function handleFileInput(e: Event) {
119+
if (!(e.target instanceof HTMLInputElement)) return;
120+
const file = e.target.files?.[0];
121+
if (file) uploadAndInsert(file).catch(console.error);
122+
// Reset so same file can be selected again
123+
e.target.value = "";
124+
}
36125
37126
// Real-time slug validation
38127
let isSlugValid = $derived(slug.length === 0 || validateArticleSlug(slug));
@@ -186,7 +275,38 @@
186275
>
187276
Preview
188277
</button>
189-
<span class="ml-auto text-xs text-zinc-500">Markdown supported</span>
278+
279+
{#if !showPreview}
280+
<button
281+
type="button"
282+
onclick={handleAttachClick}
283+
disabled={imageUploading}
284+
class="ml-2 flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700 disabled:opacity-50"
285+
title="Attach image"
286+
>
287+
{#if imageUploading}
288+
<Loader2 class="h-4 w-4 animate-spin" />
289+
{:else}
290+
<ImagePlus class="h-4 w-4" />
291+
{/if}
292+
Attach image
293+
</button>
294+
<input
295+
bind:this={fileInputEl}
296+
type="file"
297+
accept="image/*"
298+
class="hidden"
299+
oninput={handleFileInput}
300+
/>
301+
{/if}
302+
303+
<span class="ml-auto text-xs text-zinc-500">
304+
{#if imageUploading}
305+
Uploading image...
306+
{:else}
307+
Markdown supported
308+
{/if}
309+
</span>
190310
</div>
191311

192312
<!-- Content Area -->
@@ -202,14 +322,19 @@
202322
{:else}
203323
<textarea
204324
id="content"
325+
bind:this={textareaEl}
205326
bind:value={content}
327+
onpaste={handleContentPaste}
206328
class="min-h-[60vh] w-full resize-none border-none bg-transparent font-[JetBrains_Mono,monospace] text-base leading-relaxed text-zinc-900 placeholder:text-zinc-300 focus:ring-0 focus:outline-none"
207329
class:text-red-600={contentError}
208330
placeholder="Write your content here..."
209331
></textarea>
210332
{#if contentError}
211333
<p class="text-sm text-red-500">{contentError}</p>
212334
{/if}
335+
{#if imageError}
336+
<p class="text-sm text-red-500">{imageError}</p>
337+
{/if}
213338
{/if}
214339
</div>
215340
</div>

0 commit comments

Comments
 (0)