|
1 | 1 | <script lang="ts"> |
| 2 | + import { ImagePlus, Loader2 } from "lucide-svelte"; |
2 | 3 | import Markdown from "../Markdown.svelte"; |
3 | 4 | import ImageUpload from "../image-upload.svelte"; |
4 | 5 | import { |
|
7 | 8 | generateSlug, |
8 | 9 | validateArticleSlug, |
9 | 10 | } 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"; |
10 | 14 |
|
11 | 15 | let { |
12 | 16 | title = $bindable(""), |
|
33 | 37 | } = $props(); |
34 | 38 |
|
35 | 39 | 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 = ``; |
| 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 = ``; |
| 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 | + } |
36 | 125 |
|
37 | 126 | // Real-time slug validation |
38 | 127 | let isSlugValid = $derived(slug.length === 0 || validateArticleSlug(slug)); |
|
186 | 275 | > |
187 | 276 | Preview |
188 | 277 | </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> |
190 | 310 | </div> |
191 | 311 |
|
192 | 312 | <!-- Content Area --> |
|
202 | 322 | {:else} |
203 | 323 | <textarea |
204 | 324 | id="content" |
| 325 | + bind:this={textareaEl} |
205 | 326 | bind:value={content} |
| 327 | + onpaste={handleContentPaste} |
206 | 328 | 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" |
207 | 329 | class:text-red-600={contentError} |
208 | 330 | placeholder="Write your content here..." |
209 | 331 | ></textarea> |
210 | 332 | {#if contentError} |
211 | 333 | <p class="text-sm text-red-500">{contentError}</p> |
212 | 334 | {/if} |
| 335 | + {#if imageError} |
| 336 | + <p class="text-sm text-red-500">{imageError}</p> |
| 337 | + {/if} |
213 | 338 | {/if} |
214 | 339 | </div> |
215 | 340 | </div> |
|
0 commit comments